4446 lines
281 KiB
HTML
4446 lines
281 KiB
HTML
<!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;z-index:10;background:rgba(255,255,255,.92)}
|
||
.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(6,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:300px minmax(0,1fr);gap:20px}
|
||
@media (max-width:1080px){.device-layout{grid-template-columns:1fr}}
|
||
.device-list{display:flex;flex-direction:column;gap:8px;max-height:600px;overflow-y:auto;scrollbar-width:thin;padding-right:4px}
|
||
.device-list::-webkit-scrollbar{width:3px}
|
||
.device-list::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:3px}
|
||
.device-item{display:flex;align-items:center;gap:12px;padding:14px 16px;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:14px;font-weight:700}
|
||
.device-meta{font-size:11.5px;color:var(--sub);margin-top:3px}
|
||
.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)}
|
||
.status-dot.unauthorized{background:var(--orange);box-shadow:0 0 6px rgba(245,158,11,.4)}
|
||
.status-dot.offline{background:var(--red);box-shadow:0 0 6px rgba(220,38,38,.3)}
|
||
.device-item.unauth{border-color:rgba(245,158,11,.25);background:rgba(245,158,11,.04)}
|
||
|
||
/* device detail right pane */
|
||
.device-main{display:flex;flex-direction:column;gap:16px}
|
||
.device-main .detail-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||
@media (max-width:900px){.device-main .detail-row{grid-template-columns:1fr}}
|
||
.device-main .detail-wide{grid-column:1/-1}
|
||
|
||
/* health bar */
|
||
.health-bar{height:6px;border-radius:3px;background:rgba(15,23,42,.06);overflow:hidden;margin-top:6px}
|
||
.health-bar-fill{height:100%;border-radius:3px;transition:width .5s var(--ease)}
|
||
.health-score{font-size:28px;font-weight:800;letter-spacing:-.04em;line-height:1}
|
||
.health-label{font-size:11px;color:var(--sub);margin-top:2px}
|
||
|
||
/* 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(3,minmax(0,1fr));gap:10px;margin-top:14px}
|
||
@media(max-width:1280px){.mode-detail-grid{grid-template-columns:repeat(3,1fr)}}
|
||
@media(max-width:720px){.mode-detail-grid{grid-template-columns:repeat(2,1fr)}}
|
||
.mode-detail-card{width:100%;padding:16px 12px;border-radius:14px;border:1px solid var(--border);background:var(--panel-strong);text-align:center;transition:all .25s var(--ease);cursor:pointer;font:inherit;color:inherit}
|
||
.mode-detail-card:hover{border-color:var(--border-active);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.04)}
|
||
.mode-detail-card.active{border-color:var(--green);background:rgba(22,163,74,.03);box-shadow:0 0 0 2px rgba(22,163,74,.1)}
|
||
.mode-detail-card.best-active{border-color:var(--blue);background:rgba(37,99,235,.03);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:24px;margin-bottom:6px}
|
||
.mode-detail-card .mode-name{font-size:12.5px;font-weight:700}
|
||
.mode-detail-card .mode-status{font-size:11px;margin-top:6px;font-weight:500}
|
||
.mode-detail-card .mode-prio{font-size:10px;color:var(--muted);margin-top:4px;line-height:1.4}
|
||
.capability-hub{position:relative;overflow:hidden;background:linear-gradient(135deg,#f8fbff 0%,#eef7ff 52%,#f0fbfa 100%);border:1px solid rgba(37,99,235,.14)}
|
||
.capability-hub:before{content:'';position:absolute;width:260px;height:260px;right:-110px;top:-150px;border-radius:50%;background:rgba(14,165,166,.11);pointer-events:none}
|
||
.capability-hub-head{position:relative;display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||
.capability-hub-kicker{display:flex;align-items:center;gap:7px;color:var(--blue);font-size:11px;font-weight:750;letter-spacing:.04em}
|
||
.capability-hub-kicker i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px rgba(22,163,74,.12)}
|
||
.capability-hub-title{font-size:20px;font-weight:800;letter-spacing:-.02em;margin-top:5px}
|
||
.capability-hub-sub{font-size:11px;color:var(--sub);margin-top:4px}
|
||
.capability-hub-status{padding:7px 10px;border-radius:20px;background:rgba(255,255,255,.82);border:1px solid rgba(37,99,235,.12);font-size:10px;color:var(--sub);white-space:nowrap}
|
||
.capability-grid{position:relative;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:18px}
|
||
.capability-card{position:relative;display:flex;flex-direction:column;min-height:174px;padding:16px;border:1px solid rgba(148,163,184,.23);border-radius:18px;background:rgba(255,255,255,.86);box-shadow:0 8px 22px rgba(37,99,235,.055);text-align:left;cursor:pointer;transition:transform .2s var(--ease),box-shadow .2s var(--ease),border-color .2s var(--ease)}
|
||
.capability-card:hover{transform:translateY(-3px);border-color:rgba(37,99,235,.35);box-shadow:0 14px 28px rgba(37,99,235,.12)}
|
||
.capability-card-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
|
||
.capability-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:13px;font-size:22px;background:linear-gradient(135deg,rgba(37,99,235,.12),rgba(14,165,166,.12))}
|
||
.capability-index{font-size:10px;color:var(--muted);font-weight:750;letter-spacing:.08em}
|
||
.capability-name{margin-top:14px;font-size:15px;font-weight:800;color:var(--text)}
|
||
.capability-desc{margin-top:5px;min-height:30px;font-size:10.5px;line-height:1.5;color:var(--sub)}
|
||
.capability-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;padding-top:12px}
|
||
.capability-state{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--sub)}
|
||
.capability-state i{width:6px;height:6px;border-radius:50%;background:var(--muted)}
|
||
.capability-state.ready{color:var(--green)} .capability-state.ready i{background:var(--green);box-shadow:0 0 0 3px rgba(22,163,74,.1)}
|
||
.capability-enter{font-size:10px;color:var(--blue);font-weight:750}
|
||
.capability-note{position:relative;margin-top:12px;padding:9px 11px;border-radius:11px;background:rgba(255,255,255,.62);font-size:10px;color:var(--sub)}
|
||
@media(max-width:720px){.capability-grid{grid-template-columns:1fr}.capability-card{min-height:140px}.capability-hub-title{font-size:18px}}
|
||
.phone-console-card{padding:16px;border:1px solid rgba(37,99,235,.15);border-radius:18px;background:linear-gradient(145deg,#fff 0%,#f6faff 100%);box-shadow:0 8px 24px rgba(37,99,235,.05)}
|
||
.phone-console-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:14px}
|
||
.phone-console-title{font-size:14px;font-weight:800}.phone-console-sub{font-size:10px;color:var(--sub);margin-top:3px}
|
||
.phone-console-state{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--green);font-weight:700}.phone-console-state i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px rgba(22,163,74,.1)}
|
||
.phone-primary-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||
.phone-primary-action{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:72px;border:1px solid var(--border);border-radius:13px;background:#fff;color:var(--text);font-size:10.5px;font-weight:700;cursor:pointer;transition:all .18s}
|
||
.phone-primary-action:hover{border-color:rgba(37,99,235,.38);background:rgba(37,99,235,.05);transform:translateY(-2px)}
|
||
.phone-primary-action b{font-size:21px;font-weight:500}.phone-more{margin-top:10px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.72);overflow:hidden}.phone-more summary{list-style:none;padding:10px 12px;cursor:pointer;font-size:11px;font-weight:750;color:var(--text)}.phone-more summary::-webkit-details-marker{display:none}.phone-more summary:after{content:'+';float:right;color:var(--muted);font-size:14px}.phone-more[open] summary{color:var(--blue);border-bottom:1px solid var(--border)}.phone-more[open] summary:after{content:'-'}.phone-more-body{padding:12px}
|
||
@media(max-width:720px){.phone-primary-actions{grid-template-columns:repeat(2,1fr)}}
|
||
.phone-live-body{display:grid;grid-template-columns:minmax(0,1fr) 92px;gap:10px;align-items:start}
|
||
.phone-live-actions{display:flex;flex-direction:column;gap:6px}
|
||
.phone-live-action{display:flex;flex-direction:column;align-items:center;gap:3px;padding:8px 3px;border:1px solid var(--border);border-radius:10px;background:#fff;color:var(--text);font-size:9px;font-weight:700;cursor:pointer;transition:all .15s}
|
||
.phone-live-action b{font-size:16px;font-weight:500}.phone-live-action:hover{border-color:rgba(37,99,235,.35);background:rgba(37,99,235,.05)}
|
||
@media(max-width:720px){.phone-live-body{grid-template-columns:1fr}.phone-live-actions{display:grid;grid-template-columns:repeat(4,1fr)}}
|
||
.device-admin-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||
.device-admin-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 6px;border:1px solid var(--border);border-radius:11px;background:#fff;color:var(--text);font-size:10.5px;font-weight:700;cursor:pointer;transition:all .15s}.device-admin-btn b{font-size:16px;font-weight:500}.device-admin-btn:hover{border-color:rgba(37,99,235,.35);background:rgba(37,99,235,.05)}
|
||
.service-summary-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}
|
||
.service-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 8px;border-radius:20px;background:rgba(148,163,184,.1);color:var(--sub);font-size:9.5px}.service-chip i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.service-chip.ready{color:var(--green);background:rgba(22,163,74,.08)}.service-chip.ready i{background:var(--green)}
|
||
@media(max-width:720px){.device-admin-actions{grid-template-columns:repeat(2,1fr)}}
|
||
|
||
/* 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}
|
||
|
||
/* application control: one app per entry, details revealed on demand */
|
||
.app-control-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
|
||
@media(max-width:900px){.app-control-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||
@media(max-width:560px){.app-control-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
.app-control-card{display:flex;align-items:center;gap:10px;padding:13px 12px;border:1px solid var(--border);border-radius:15px;background:rgba(255,255,255,.82);cursor:pointer;text-align:left;transition:all .2s var(--ease)}
|
||
.app-control-card:hover{border-color:var(--border-active);transform:translateY(-2px);box-shadow:0 8px 18px rgba(37,99,235,.08)}
|
||
.app-control-card.active{border-color:rgba(37,99,235,.35);background:linear-gradient(135deg,rgba(37,99,235,.1),rgba(14,165,166,.08));box-shadow:0 0 0 2px rgba(37,99,235,.08)}
|
||
.app-control-icon{width:36px;height:36px;border-radius:11px;display:grid;place-items:center;font-size:19px;background:rgba(15,23,42,.05);flex-shrink:0}
|
||
.app-control-name{font-size:12px;font-weight:750;color:var(--text)}
|
||
.app-control-desc{font-size:10px;color:var(--sub);margin-top:2px;line-height:1.35}
|
||
.app-control-panel{margin-top:12px;padding:14px;border:1px solid rgba(37,99,235,.14);border-radius:16px;background:rgba(248,250,252,.72)}
|
||
.app-control-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:12px}
|
||
.app-control-panel-title{font-size:14px;font-weight:750}
|
||
.app-control-panel-sub{font-size:10.5px;color:var(--sub);margin-top:3px}
|
||
.app-entry-focus{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid rgba(37,99,235,.24);border-radius:16px;background:linear-gradient(135deg,rgba(37,99,235,.1),rgba(14,165,166,.08));margin-bottom:10px}
|
||
.app-entry-focus .app-control-icon{width:44px;height:44px;font-size:23px;background:#fff;box-shadow:0 4px 12px rgba(37,99,235,.12)}
|
||
.app-entry-focus .app-control-name{font-size:14px}
|
||
.app-entry-focus .app-control-desc{font-size:10.5px}
|
||
.app-entry-focus .btn{margin-left:auto;white-space:nowrap;font-size:11px;padding:7px 12px}
|
||
.app-entry-nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}
|
||
.app-entry-nav .app-control-card{padding:8px 9px;gap:7px;border-radius:11px}
|
||
.app-entry-nav .app-control-icon{width:27px;height:27px;border-radius:8px;font-size:15px}
|
||
.app-entry-nav .app-control-name{font-size:10.5px}
|
||
.app-entry-nav .app-control-desc{display:none}
|
||
.wx-result-card{font-size:11px;color:var(--text);line-height:1.55}
|
||
.wx-result-card .wx-result-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;font-weight:750}
|
||
.wx-result-card .wx-result-badge{font-size:9px;color:var(--green);background:rgba(22,163,74,.1);padding:2px 7px;border-radius:20px}
|
||
.wx-result-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
|
||
.wx-result-item{padding:7px 8px;background:rgba(37,99,235,.045);border-radius:8px;min-width:0}
|
||
.wx-result-item small{display:block;color:var(--sub);font-size:9px;margin-bottom:2px}
|
||
.wx-result-item b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.wx-result-list{display:flex;flex-direction:column;gap:5px;max-height:180px;overflow:auto}
|
||
.wx-result-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border:1px solid var(--border);border-radius:8px;background:#fff}
|
||
.wx-result-row small{color:var(--sub);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.wx-result-empty{padding:10px;border-radius:8px;background:rgba(245,158,11,.08);color:#92400e;font-size:10.5px}
|
||
.wx-result-error{padding:9px 10px;border-radius:8px;background:rgba(220,38,38,.07);color:var(--red);font-size:10.5px;line-height:1.55}
|
||
.app-fold{border:1px solid var(--border);border-radius:13px;background:rgba(255,255,255,.8);margin-top:9px;overflow:hidden}
|
||
.app-fold summary{list-style:none;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:11px 13px;cursor:pointer;font-size:11.5px;font-weight:700;color:var(--text)}
|
||
.app-fold summary::-webkit-details-marker{display:none}
|
||
.app-fold summary::after{content:'+';color:var(--muted);font-size:15px;font-weight:400}
|
||
.app-fold[open] summary{border-bottom:1px solid var(--border);color:var(--blue)}
|
||
.app-fold[open] summary::after{content:'-'}
|
||
.app-fold-body{padding:12px}
|
||
.app-action-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||
@media(max-width:720px){.app-action-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
.app-action-btn{display:flex;align-items:center;gap:8px;padding:10px 9px;border:1px solid var(--border);border-radius:11px;background:#fff;color:var(--text);cursor:pointer;font-size:11px;text-align:left;transition:all .15s}
|
||
.app-action-btn:hover{border-color:var(--border-active);background:rgba(37,99,235,.05);transform:translateY(-1px)}
|
||
.app-action-btn .icon{font-size:17px;width:22px;text-align:center}
|
||
.app-disabled{opacity:.5;pointer-events:none}
|
||
|
||
/* 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}
|
||
|
||
/* realtime device control */
|
||
.shot{aspect-ratio:9/19.5;border-radius:30px;overflow:hidden;border:7px solid #111827;background:#0b1220;display:flex;align-items:center;justify-content:center;box-shadow:0 18px 42px rgba(15,23,42,.22),inset 0 0 0 1px rgba(255,255,255,.08);position:relative}
|
||
.shot img{display:block;width:auto;max-width:100%;height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}
|
||
.shot canvas{display:block;width:auto;max-width:100%;height:100%;object-fit:contain;user-select:none}
|
||
.shot .live-screen-fallback{display:none}
|
||
.live-device-card{position:sticky;top:12px;background:linear-gradient(180deg,rgba(255,255,255,.98),rgba(248,250,252,.96))}
|
||
.live-device-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
|
||
.live-device-title{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:800}
|
||
.live-device-status{display:inline-flex;align-items:center;gap:5px;padding:4px 9px;border-radius:999px;background:rgba(22,163,74,.1);color:var(--green);font-size:10px;font-weight:700}
|
||
.live-device-status::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px rgba(22,163,74,.1)}
|
||
.live-device-status.waiting{background:rgba(245,158,11,.1);color:#b45309}
|
||
.live-device-status.error{background:rgba(220,38,38,.08);color:var(--red)}
|
||
.live-device-hint{text-align:center;font-size:10px;color:var(--muted);margin:8px 0 2px}
|
||
.live-device-tools{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-top:10px}
|
||
.live-device-tools .phone-live-action{min-height:50px}
|
||
.device-control-layout{display:grid;grid-template-columns:minmax(390px,460px) minmax(0,1fr);gap:12px;margin-top:10px;align-items:start}
|
||
.device-live-column{order:1;display:flex;flex-direction:column;gap:8px;min-width:0}
|
||
.device-tools-column{order:2;display:flex;flex-direction:column;gap:8px;min-width:0}
|
||
.device-tools-column>.card,.device-tools-column>.phone-console-card{padding:10px}
|
||
.device-tools-column .phone-console-card{border-radius:14px}
|
||
.device-tools-column .phone-console-head{margin-bottom:8px}
|
||
.device-tools-column .device-admin-actions{gap:6px}
|
||
.device-tools-column .device-admin-btn{padding:8px 5px;font-size:10px}
|
||
.device-tools-column .service-summary-row{margin-top:7px;padding-top:7px;gap:4px}
|
||
.device-tools-column .service-chip{font-size:9px;padding:3px 7px}
|
||
.control-section{border:1px solid var(--border);border-radius:14px;background:rgba(255,255,255,.9);overflow:hidden;box-shadow:0 4px 14px rgba(15,23,42,.035)}
|
||
.control-section>summary{list-style:none;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 13px;cursor:pointer;font-size:12px;font-weight:750;color:var(--text);transition:background .16s}
|
||
.control-section>summary::-webkit-details-marker{display:none}
|
||
.control-section>summary:hover{background:rgba(37,99,235,.045)}
|
||
.control-section>summary::after{content:'+';font-size:17px;font-weight:400;color:var(--muted)}
|
||
.control-section[open]>summary{color:var(--blue);border-bottom:1px solid var(--border)}
|
||
.control-section[open]>summary::after{content:'-'}
|
||
.control-section-body{padding:11px 13px}
|
||
.control-section-kicker{font-size:9.5px;font-weight:600;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.control-section .phone-console-head{margin:0 0 9px}.control-section .phone-console-title{font-size:12px}.control-section .phone-console-sub{font-size:9.5px}
|
||
.control-section .app-entry-nav{margin-bottom:10px!important}.control-section .app-action-grid{gap:6px}
|
||
.control-section .app-action-btn{padding:8px 7px;font-size:10px}.control-section .app-action-btn .icon{font-size:15px}
|
||
.device-summary-main{display:flex;align-items:center;gap:9px}
|
||
.device-summary-icon{width:38px;height:38px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:18px;color:#fff;flex:0 0 auto}
|
||
.device-summary-copy{flex:1;min-width:0}
|
||
.device-summary-name{font-size:14px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.device-summary-id{font-size:9.5px;color:var(--sub);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.device-summary-state{text-align:right;flex:0 0 auto}
|
||
.device-metric-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;margin-top:8px}
|
||
.device-mini-metric{padding:6px;border-radius:9px;background:rgba(37,99,235,.045);text-align:center;min-width:0}
|
||
.device-mini-metric small{display:block;font-size:8.5px;color:var(--sub);margin-bottom:1px}
|
||
.device-mini-metric b{display:block;font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.compact-device-more{margin-top:7px;border-top:1px solid var(--border)}
|
||
.compact-device-more summary{list-style:none;cursor:pointer;padding:7px 1px 0;font-size:9.5px;color:var(--sub);font-weight:700}
|
||
.compact-device-more summary::-webkit-details-marker{display:none}
|
||
.compact-device-more summary::after{content:'+';float:right;color:var(--muted)}
|
||
.compact-device-more[open] summary::after{content:'-'}
|
||
.compact-device-body{padding-top:7px}
|
||
.compact-meta-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px;margin-top:6px}
|
||
.compact-meta-item{padding:6px 7px;border-radius:8px;background:rgba(15,23,42,.035);min-width:0}
|
||
.compact-meta-item small{display:block;font-size:8px;color:var(--muted)}
|
||
.compact-meta-item b{display:block;font-size:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.device-detail-header{padding:10px 14px!important}
|
||
.device-detail-header h2{font-size:14px}
|
||
.device-detail-header .sub{font-size:9.5px;margin-top:1px}
|
||
.control-feedback{box-shadow:0 0 0 4px rgba(37,99,235,.14)!important}
|
||
.phone-live-action:active,.device-admin-btn:active,.app-control-card:active,.app-action-btn:active{transform:scale(.96)}
|
||
@media(max-width:1100px){
|
||
.live-device-card{position:static}
|
||
.device-control-layout{grid-template-columns:minmax(340px,410px) minmax(0,1fr)}
|
||
}
|
||
@media(max-width:860px){
|
||
.device-control-layout{grid-template-columns:1fr}
|
||
.device-live-column,.device-tools-column{order:initial}
|
||
.device-live-column{order:1}.device-tools-column{order:2}
|
||
}
|
||
.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}
|
||
|
||
/* ═══ Connection Status Bar ═══ */
|
||
.conn-bar{position:fixed;bottom:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:space-between;padding:8px 24px;background:rgba(15,23,42,.92);color:#e2e8f0;font-size:12px;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-top:1px solid rgba(255,255,255,.06);gap:12px}
|
||
.conn-bar .conn-group{display:flex;align-items:center;gap:16px}
|
||
.conn-bar .conn-item{display:flex;align-items:center;gap:6px;font-weight:500}
|
||
.conn-bar .conn-dot{width:8px;height:8px;border-radius:999px;flex-shrink:0}
|
||
.conn-bar .conn-dot.green{background:#22c55e;box-shadow:0 0 8px rgba(34,197,94,.5)}
|
||
.conn-bar .conn-dot.yellow{background:#eab308;box-shadow:0 0 8px rgba(234,179,8,.4)}
|
||
.conn-bar .conn-dot.red{background:#ef4444;box-shadow:0 0 8px rgba(239,68,68,.4)}
|
||
.conn-bar .conn-dot.pulse{animation:pulse-dot 2s infinite}
|
||
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.conn-bar .conn-action{padding:4px 12px;border-radius:6px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.08);color:#fff;font-size:11px;cursor:pointer;transition:.2s;font-weight:600}
|
||
.conn-bar .conn-action:hover{background:rgba(255,255,255,.18)}
|
||
|
||
/* ═══ Discovery Panel ═══ */
|
||
.discovery-card{padding:20px;border-radius:16px;border:1px solid rgba(37,99,235,.15);background:linear-gradient(135deg,rgba(37,99,235,.04),rgba(14,165,166,.03));transition:all .25s var(--ease)}
|
||
.discovery-card:hover{border-color:rgba(37,99,235,.25);box-shadow:0 8px 24px rgba(37,99,235,.08)}
|
||
.scan-progress{height:4px;border-radius:2px;background:rgba(37,99,235,.1);overflow:hidden;margin-top:12px}
|
||
.scan-progress-bar{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--blue),var(--teal));animation:scan-move 2s ease-in-out infinite;width:30%;transform-origin:left}
|
||
@keyframes scan-move{0%{transform:translateX(-100%)}50%{transform:translateX(200%)}100%{transform:translateX(400%)}}
|
||
|
||
/* ═══ KPI Hero ═══ */
|
||
.kpi-hero{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:16px}
|
||
@media(max-width:980px){.kpi-hero{grid-template-columns:1fr}}
|
||
.kpi-north-star{grid-column:1;grid-row:1/3;padding:28px 24px;border-radius:var(--radius);background:linear-gradient(135deg,var(--blue),var(--teal));color:#fff;display:flex;flex-direction:column;justify-content:center;box-shadow:0 12px 32px rgba(37,99,235,.2)}
|
||
.kpi-north-star .kpi-value{font-size:56px;font-weight:900;letter-spacing:-.06em;line-height:1}
|
||
.kpi-north-star .kpi-label{font-size:15px;font-weight:600;margin-top:8px;opacity:.9}
|
||
.kpi-north-star .kpi-sub{font-size:12px;margin-top:4px;opacity:.7}
|
||
.kpi-secondary{padding:20px;border-radius:var(--radius);background:var(--panel);border:1px solid var(--border);backdrop-filter:blur(var(--blur));-webkit-backdrop-filter:blur(var(--blur))}
|
||
.kpi-secondary .kpi-value{font-size:28px;font-weight:800;letter-spacing:-.04em;background:linear-gradient(135deg,var(--text),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||
.kpi-secondary .kpi-label{font-size:12px;color:var(--sub);font-weight:500;margin-top:4px}
|
||
|
||
/* ═══ Quick Action Grid ═══ */
|
||
.quick-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
|
||
.quick-item{display:flex;align-items:center;gap:10px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);background:var(--panel-strong);cursor:pointer;transition:all .2s var(--ease)}
|
||
.quick-item:hover{border-color:var(--border-active);transform:translateY(-2px);box-shadow:0 6px 16px rgba(37,99,235,.08)}
|
||
.quick-item .qi-icon{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-size:18px;flex-shrink:0}
|
||
.quick-item .qi-text{font-size:12.5px;font-weight:600}
|
||
.quick-item .qi-desc{font-size:10.5px;color:var(--sub);margin-top:2px}
|
||
|
||
/* ═══ Responsive padding for conn-bar ═══ */
|
||
.app{padding-bottom:64px}
|
||
|
||
.device-fleet-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px}
|
||
.device-fleet-strip>div{margin-bottom:0!important;min-height:86px}
|
||
@media(max-width:980px){.device-fleet-strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
@media(max-width:640px){.device-fleet-strip{grid-template-columns:1fr}}
|
||
.phone-device-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px;margin-top:16px}
|
||
.phone-device-card{position:relative;display:flex;min-height:320px;flex-direction:column;align-items:center;padding:22px 16px 16px;border:1px solid var(--border);border-radius:22px;background:rgba(255,255,255,.82);box-shadow:var(--shadow);cursor:pointer;color:var(--text);transition:all .28s var(--ease)}
|
||
.phone-device-card:hover{transform:translateY(-4px);border-color:var(--border-active);box-shadow:var(--shadow-lg)}
|
||
.phone-online-dot{position:absolute;right:16px;top:16px;width:9px;height:9px;border-radius:50%;box-shadow:0 0 0 5px rgba(22,163,74,.09)}
|
||
.phone-shell{position:relative;width:76px;height:138px;padding:9px 6px 7px;border:4px solid #172033;border-radius:20px;background:#172033;box-shadow:0 16px 30px rgba(15,23,42,.16)}
|
||
.phone-speaker{position:absolute;z-index:2;left:50%;top:5px;width:22px;height:3px;transform:translateX(-50%);border-radius:4px;background:#64748b}
|
||
.phone-screen{display:flex;width:100%;height:100%;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-radius:12px;background:linear-gradient(145deg,#eff6ff,#ecfeff);font-size:10px;font-weight:700;color:var(--blue)}
|
||
.phone-screen-icon{font-size:27px}.phone-device-card strong{margin-top:16px;font-size:14px}.phone-device-card small{max-width:100%;margin-top:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:10px}
|
||
.phone-tags{display:flex;flex-wrap:wrap;justify-content:center;gap:5px;margin-top:11px}.phone-tags i{padding:4px 7px;border-radius:999px;background:rgba(15,23,42,.04);font-size:9px;font-style:normal;color:var(--sub)}
|
||
.phone-enter{margin-top:auto;padding-top:14px;font-size:10px;font-weight:700;color:var(--blue)}
|
||
.overview-hero{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:26px 28px;background:linear-gradient(125deg,rgba(255,255,255,.92),rgba(239,246,255,.88))}.overview-hero h2{font-size:26px;margin:5px 0 4px;letter-spacing:-.04em}.overview-hero p{font-size:12px;color:var(--sub)}
|
||
.section-eyebrow{font-size:10px;font-weight:800;letter-spacing:.12em;color:var(--blue)}.section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.section-title h3{font-size:16px;margin-top:3px}
|
||
.overview-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:14px}.overview-metric{display:flex;align-items:center;gap:14px;padding:18px}.overview-metric>span{display:grid;width:42px;height:42px;place-items:center;border-radius:13px;background:rgba(37,99,235,.08);color:var(--blue)}.overview-metric small,.overview-metric strong,.overview-metric p{display:block}.overview-metric small{font-size:10px;color:var(--sub)}.overview-metric strong{font-size:25px;line-height:1.2;margin-top:2px}.overview-metric p{font-size:9px;color:var(--muted);margin-top:2px}
|
||
.overview-quick{margin-top:14px;padding:20px}.overview-quick-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.overview-quick-grid button{display:flex;min-height:86px;flex-direction:column;align-items:flex-start;justify-content:center;padding:14px;border:1px solid var(--border);border-radius:15px;background:rgba(255,255,255,.84);cursor:pointer;text-align:left;transition:.2s}.overview-quick-grid button:hover{transform:translateY(-2px);border-color:var(--border-active)}.overview-quick-grid i{font-size:20px;font-style:normal}.overview-quick-grid b{font-size:12px;margin-top:7px}.overview-quick-grid small{font-size:9px;color:var(--muted);margin-top:3px}
|
||
.overview-columns{display:grid;grid-template-columns:1.15fr .85fr;gap:14px;margin-top:14px}.overview-columns>.panel{margin:0}.overview-columns .grid.col2{grid-template-columns:1fr}.overview-device-list{display:grid;gap:7px}.overview-device-row{display:grid;grid-template-columns:38px minmax(0,1fr) auto auto;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.78);cursor:pointer;text-align:left}.overview-device-icon{display:grid;width:36px;height:36px;place-items:center;border-radius:11px;background:rgba(37,99,235,.07)}.overview-device-row b,.overview-device-row small{display:block}.overview-device-row b{font-size:11px}.overview-device-row small{font-size:9px;color:var(--muted);margin-top:2px}.overview-device-row i{font-size:10px;font-style:normal}.overview-device-row em{font-size:9px;font-style:normal;color:var(--blue)}
|
||
.engine-nav-groups{display:grid;grid-template-columns:1fr 1.2fr 1.6fr;gap:10px}.engine-nav-group{padding:10px;border-radius:14px;background:rgba(15,23,42,.025)}.engine-nav-group>small{display:block;padding:0 3px 7px;font-size:9px;font-weight:800;color:var(--muted);letter-spacing:.1em}.engine-nav-actions{display:flex;flex-wrap:wrap;gap:5px}.engine-nav-actions .mode-btn{margin:0;flex:1;min-width:max-content;text-align:center}
|
||
@media(max-width:1050px){.overview-metrics,.overview-quick-grid{grid-template-columns:repeat(2,1fr)}.overview-columns,.engine-nav-groups{grid-template-columns:1fr}}
|
||
@media(max-width:640px){.overview-hero{align-items:flex-start;flex-direction:column}.overview-metrics,.overview-quick-grid{grid-template-columns:1fr}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<header class="topbar glass">
|
||
<div class="brand">
|
||
<div class="brand-icon">⚙️</div>
|
||
<div>
|
||
<h1>工作手机控制台</h1>
|
||
<p>多手机统一管控 · 设备管理 · 智能引擎 · 对接网关 · 知识库</p>
|
||
</div>
|
||
</div>
|
||
<div class="chips">
|
||
<button class="chip" id="chip-auth" onclick="toggleConsoleAuth()" style="cursor:pointer;border:0">🔐 控制台登录</button>
|
||
<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">工作手机 SDK v3.0 · 机擎 v4.0 · 设备管理 · 智能引擎 · 对接网关 · 知识库</div>
|
||
</div>
|
||
|
||
<div class="conn-bar" id="conn-bar">
|
||
<div class="conn-group">
|
||
<div class="conn-item"><span class="conn-dot" id="cb-ws-dot"></span><span id="cb-ws-text">WS 检测中</span></div>
|
||
<div class="conn-item"><span class="conn-dot" id="cb-adb-dot"></span><span id="cb-adb-text">ADB 检测中</span></div>
|
||
<div class="conn-item"><span class="conn-dot" id="cb-beacon-dot"></span><span id="cb-beacon-text">Beacon 检测中</span></div>
|
||
<div class="conn-item" id="cb-discovery-status" style="display:none"><span class="conn-dot yellow pulse"></span><span>正在扫描局域网...</span></div>
|
||
</div>
|
||
<div class="conn-group">
|
||
<span id="cb-server-ip" style="color:rgba(255,255,255,.5);font-size:11px"></span>
|
||
<button class="conn-action" onclick="scanLanDevices()">🔍 扫描局域网</button>
|
||
<button class="conn-action" onclick="refreshAll()">🔄 刷新全部</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
||
<button class="lb-close" onclick="closeLightbox()">×</button>
|
||
<img id="lb-img" src="" alt="放大预览">
|
||
<div class="lb-title" id="lb-title"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = (function() {
|
||
if (location.protocol === 'file:' && typeof NativeAgent !== 'undefined') {
|
||
try {
|
||
const cfg = JSON.parse(NativeAgent.getConfig());
|
||
const ws = cfg.server_url || '';
|
||
const m = ws.match(/^wss?:\/\/([^/]+)/);
|
||
if (m) return 'http://' + m[1];
|
||
} catch(e) {}
|
||
}
|
||
return location.origin !== 'null' ? location.origin : '';
|
||
})();
|
||
const NAVS = [
|
||
{id:'overview', icon:'📊', name:'数据总览', desc:'数据 · 快捷入口 · 扫码'},
|
||
{id:'devices', icon:'📱', name:'手机设备', desc:'设备列表 · 状态 · 进入控制'},
|
||
{id:'engine', icon:'🧠', name:'智能引擎', desc:'AI Brain · Hook · 心跳'},
|
||
{id:'gateway', icon:'🔗', name:'对接网关', desc:'REST · MCP · OpenAI'},
|
||
{id:'docs', icon:'📖', name:'知识库', desc:'文档 · 架构图 · 手册'},
|
||
];
|
||
|
||
const _validTabs = NAVS.map(n => n.id);
|
||
const _urlTab = new URLSearchParams(location.search).get('tab');
|
||
const _resolvedTab = _urlTab === 'wechat' ? 'devices' : (_urlTab === 'infra' ? 'engine' : _urlTab);
|
||
const state = {
|
||
current: _validTabs.includes(_resolvedTab) ? _resolvedTab : 'overview',
|
||
overview: null,
|
||
docs: [],
|
||
assets: [],
|
||
selectedDoc: null,
|
||
docFilter: '',
|
||
devices: [],
|
||
currentDevice: null,
|
||
deviceView: 'list',
|
||
deviceTab: 'control',
|
||
appFocus: 'wechat',
|
||
protocol: null,
|
||
connection: null,
|
||
hookModules: [],
|
||
hookEvents: [],
|
||
hookData: {},
|
||
hookDataLoading: false,
|
||
currentDeviceModules: null,
|
||
heartbeatDashboard: null,
|
||
heartbeatEvents: [],
|
||
gatewayTab: 'overview',
|
||
gatewayInfo: null,
|
||
fleetStatus: null,
|
||
journeys: null,
|
||
wxKeyword: '',
|
||
wxStatusFilter: 'all',
|
||
logs: [],
|
||
cmdMode: 'pattern',
|
||
engineTab: new URLSearchParams(location.search).get('engineTab') || 'brain',
|
||
skillRegistry: {},
|
||
brainDashboard: {},
|
||
brainSub: 'overview',
|
||
brainSkillFilter: 'all',
|
||
infraTab: 'connection',
|
||
docsTab: 'manual',
|
||
bindingServer: '',
|
||
selectedAgentDoc: null,
|
||
integrationManifest: null,
|
||
integrationManifestLoading: false,
|
||
integrationManifestError: '',
|
||
realtime: {devices: [], recent_events: []},
|
||
realtimeError: '',
|
||
antibanStatus: null,
|
||
aiConfig: null,
|
||
aiConfigLoading: false,
|
||
};
|
||
|
||
/* ═══ Utilities ═══ */
|
||
|
||
function esc(text=''){
|
||
return String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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();
|
||
const box = document.getElementById('device-action-logs') || document.querySelector('.log-box:not(#wx-result)');
|
||
if (box) { box.innerHTML = state.logs.map(l=>`<div class="${l.type==='error'?'log-error':'log-info'}">[${l.ts}] ${esc(l.message)}</div>`).join(''); box.scrollTop=box.scrollHeight; }
|
||
}
|
||
|
||
/* ═══ API ═══ */
|
||
|
||
let consoleAuthPromise = null;
|
||
let consoleAuthenticated = false;
|
||
|
||
function updateConsoleAuthChip(ok){
|
||
const chip = document.getElementById('chip-auth');
|
||
if (!chip) return;
|
||
consoleAuthenticated = ok;
|
||
chip.textContent = ok ? '🔐 已登录 · 退出' : '🔓 登录';
|
||
chip.style.color = ok ? 'var(--green)' : 'var(--red)';
|
||
}
|
||
|
||
async function ensureConsoleAuthenticated(){
|
||
if (consoleAuthPromise) return consoleAuthPromise;
|
||
consoleAuthPromise = (async () => {
|
||
const res = await fetch(`${API}/api/v3/console/session`, {credentials:'same-origin'});
|
||
if (res.ok) {
|
||
updateConsoleAuthChip(true);
|
||
return true;
|
||
}
|
||
updateConsoleAuthChip(false);
|
||
return false;
|
||
})().finally(() => { consoleAuthPromise = null; });
|
||
return consoleAuthPromise;
|
||
}
|
||
|
||
async function loginConsole(){
|
||
const username = document.getElementById('console-username')?.value?.trim() || '';
|
||
const password = document.getElementById('console-password')?.value || '';
|
||
const error = document.getElementById('console-login-error');
|
||
if (!username || !password) { if (error) error.textContent = '请输入账号和密码'; return; }
|
||
const res = await fetch(`${API}/api/v3/console/login`, {
|
||
method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({username,password}),
|
||
});
|
||
if (!res.ok) { if (error) error.textContent = '账号或密码错误'; return; }
|
||
updateConsoleAuthChip(true);
|
||
await bootstrap();
|
||
startPolling();
|
||
}
|
||
|
||
async function logoutConsole(){
|
||
await fetch(`${API}/api/v3/console/logout`, {method:'POST', credentials:'same-origin'});
|
||
clearInterval(_pollTimer); _pollTimer = null;
|
||
updateConsoleAuthChip(false);
|
||
await bootstrap();
|
||
}
|
||
|
||
async function toggleConsoleAuth(){
|
||
if (consoleAuthenticated) await logoutConsole();
|
||
else {
|
||
await bootstrap();
|
||
document.getElementById('console-username')?.focus();
|
||
}
|
||
}
|
||
|
||
async function authenticatedFetch(url, options={}){
|
||
if (!consoleAuthenticated && !await ensureConsoleAuthenticated()) {
|
||
throw new Error('控制台未认证');
|
||
}
|
||
const headers = new Headers(options.headers || {});
|
||
const res = await fetch(url, {...options, headers, credentials:'same-origin'});
|
||
if (res.status === 401) {
|
||
updateConsoleAuthChip(false);
|
||
}
|
||
return res;
|
||
}
|
||
|
||
async function apiErrorFromResponse(res){
|
||
const text = await res.text().catch(() => '');
|
||
let message = `请求失败: ${res.status}`;
|
||
try {
|
||
const obj = JSON.parse(text);
|
||
if (obj && typeof obj === 'object') {
|
||
message = obj.message || obj.detail || obj.error || message;
|
||
}
|
||
} catch (_e) {}
|
||
const err = new Error(message);
|
||
err.status = res.status;
|
||
err.body = text;
|
||
return err;
|
||
}
|
||
|
||
async function apiGet(url){
|
||
const res = await authenticatedFetch(`${API}${url}`);
|
||
if(!res.ok) throw await apiErrorFromResponse(res);
|
||
return res.json();
|
||
}
|
||
|
||
async function apiPost(url, body){
|
||
const res = await authenticatedFetch(`${API}${url}`, {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(body || {})
|
||
});
|
||
if(!res.ok) throw await apiErrorFromResponse(res);
|
||
return res.json();
|
||
}
|
||
|
||
async function apiDelete(url){
|
||
const res = await authenticatedFetch(`${API}${url}`, {method:'DELETE'});
|
||
if(!res.ok) throw await apiErrorFromResponse(res);
|
||
return res.json();
|
||
}
|
||
|
||
async function apiUpload(url, file){
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const res = await authenticatedFetch(`${API}${url}`, {method:'POST', body:form});
|
||
if(!res.ok) throw await apiErrorFromResponse(res);
|
||
return res.json();
|
||
}
|
||
|
||
/* ═══ Rendering Infrastructure ═══ */
|
||
|
||
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 ov = state.overview || {};
|
||
const unauth = ov.unauthorized_count ?? 0;
|
||
const adbAll = ov.adb_all ?? ov.adb_count ?? 0;
|
||
const totalDevices = ov.device_total ?? 0;
|
||
const wsCount = ov.ws_online ?? 0;
|
||
const adbCount = ov.adb_count ?? 0;
|
||
const controllable = ov.device_online ?? (wsCount + adbCount);
|
||
document.getElementById('stats').innerHTML = `
|
||
${statCard('📱 设备总数', `<span style="font-size:32px;color:${totalDevices?'var(--blue)':'var(--muted)'};">${totalDevices}</span>`, `${controllable} 可控 · ${unauth ? unauth + ' 待授权' : '全部就绪'}`)}
|
||
${statCard('🔌 WS 连接', wsCount, wsCount > 0 ? '设备在线' : '无连接')}
|
||
${statCard('📷 扫码设备', wsCount, wsCount > 0 ? '无线控制已连接' : '等待手机扫码连接')}
|
||
${statCard('🔗 API 端点', '150+', '3 种对接方式')}
|
||
${statCard('📄 知识文档', (ov.docs || state.docs || []).length || 0, '站内阅读')}
|
||
${statCard('⚡ 服务状态', ov.sdk_online ? '<span style="color:var(--green)">健康</span>' : '<span style="color:var(--red)">离线</span>', `v${ov.sdk_version || '-'}`)}
|
||
`;
|
||
document.getElementById('chip-health').innerHTML = `<span class="dot ${ov.sdk_online ? 'ok' : 'bad'}"></span>${ov.sdk_online ? '在线' : '离线'}`;
|
||
document.getElementById('chip-ws').textContent = `WS ${wsCount}`;
|
||
document.getElementById('chip-adb').textContent = `扫码 ${wsCount}`;
|
||
document.getElementById('chip-version').textContent = `v${ov.sdk_version || '-'}`;
|
||
updateConnBar();
|
||
}
|
||
|
||
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){
|
||
if (id === 'wechat') { state.current = 'devices'; state.deviceView = 'list'; }
|
||
else if (id === 'devices') { state.current = 'devices'; state.deviceView = 'list'; }
|
||
else if (id === 'infra') { state.current = 'engine'; state.engineTab = 'connection'; }
|
||
else { state.current = id; }
|
||
const url = new URL(location.href);
|
||
url.searchParams.set('tab', state.current);
|
||
history.replaceState({}, '', url.toString());
|
||
renderSidebar();
|
||
renderCurrentView();
|
||
}
|
||
|
||
function card(title, desc, action){
|
||
return `
|
||
<div class="card">
|
||
<h3>${title}</h3>
|
||
<p>${desc}</p>
|
||
${action || ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function actionBtn(icon, label, handler){
|
||
return `<div class="action" onclick="${handler}"><div class="icon">${icon}</div><div>${label}</div></div>`;
|
||
}
|
||
|
||
function cmdPlaceholder(){
|
||
if (state.cmdMode === 'pattern') return '输入指令:打开微信;然后截图';
|
||
if (state.cmdMode === 'ai') return '自然语言任务:打开微信发消息给张三';
|
||
return '解析指令:打开淘宝搜索蓝牙耳机';
|
||
}
|
||
|
||
function deviceStatusLabel(device){
|
||
const st = device.status || device.adb_status || '';
|
||
if (st === 'online') return '<span style="color:var(--green);font-weight:600">● 在线</span>';
|
||
if (st === 'adb') return '<span style="color:var(--blue);font-weight:600">● ADB</span>';
|
||
if (st === 'unauthorized') return '<span style="color:var(--orange);font-weight:600">⚠ 待授权</span>';
|
||
if (st === 'offline') return '<span style="color:var(--red);font-weight:600">○ 离线</span>';
|
||
return `<span style="color:var(--muted)">${esc(st)}</span>`;
|
||
}
|
||
|
||
function deviceControlCards(modes){
|
||
const list = Array.isArray(modes) ? modes : [];
|
||
const find = (...ids) => list.find(mode => ids.includes(mode.id));
|
||
const hook = find('hook');
|
||
const agent = find('agent','u2');
|
||
const adb = find('adb');
|
||
return [
|
||
{
|
||
id:'data', icon:'🪝', name:'数据能力',
|
||
desc:'微信资料、好友、群聊、标签',
|
||
source:hook, status:hook?.available?'已连接':'等待 Hook',
|
||
detail:hook?.available ? (hook.detail || 'Hook / Frida 已就绪') : '需要 Root + Frida Server'
|
||
},
|
||
{
|
||
id:'ui', icon:'🖱️', name:'交互操作',
|
||
desc:'打开应用、点击、输入、朋友圈',
|
||
source:agent, status:agent?.available?'已连接':'等待 Agent',
|
||
detail:agent?.available ? (agent.detail || 'Agent WebSocket 已就绪') : '需要手机端工作 App'
|
||
},
|
||
{
|
||
id:'device', icon:'🔗', name:'高级连接',
|
||
desc:'Agent、无障碍、ADB、注入引擎',
|
||
source:agent?.available ? agent : adb, status:agent?.available?'已连接':(adb?.available?'ADB兜底':'待配置'),
|
||
detail:agent?.available ? '无线主控与系统服务统一管理' : (adb?.detail || '等待设备服务上线')
|
||
}
|
||
];
|
||
}
|
||
|
||
function openControlCapability(type){
|
||
if(type === 'data'){
|
||
state.current = 'engine';
|
||
state.engineTab = 'hook';
|
||
} else if(type === 'ui'){
|
||
state.current = 'devices';
|
||
state.deviceView = 'detail';
|
||
} else {
|
||
state.current = 'devices';
|
||
state.deviceView = 'detail';
|
||
}
|
||
renderSidebar();
|
||
renderCurrentView();
|
||
}
|
||
|
||
/* ═══ 1. Overview Panel ═══ */
|
||
|
||
function renderOverview(){
|
||
const ov = state.overview || {};
|
||
const modes = ov.control_modes || [];
|
||
const devices = ov.devices || [];
|
||
const totalDevices = ov.device_total || 0;
|
||
const wsCount = ov.ws_online || 0;
|
||
const adbCount = ov.adb_count || 0;
|
||
const controllable = ov.device_online ?? (wsCount + adbCount);
|
||
const unauth = ov.unauthorized_count || 0;
|
||
const discoveredCount = state.discoveredServers?.length || 0;
|
||
|
||
return `
|
||
<!-- KPI Hero Area (F-pattern: North Star at top-left) -->
|
||
<div class="kpi-hero">
|
||
<div class="kpi-north-star glass">
|
||
<div class="kpi-value">${totalDevices}</div>
|
||
<div class="kpi-label">已连接设备</div>
|
||
<div class="kpi-sub">${controllable} 可控 · ${wsCount} WS · ${adbCount} ADB${unauth ? ` · ${unauth} 待授权` : ''}</div>
|
||
</div>
|
||
<div class="kpi-secondary glass">
|
||
<div class="kpi-value">${ov.sdk_online ? '✓ 健康' : '✗ 离线'}</div>
|
||
<div class="kpi-label">服务状态 · v${ov.sdk_version || '-'}</div>
|
||
</div>
|
||
<div class="kpi-secondary glass">
|
||
<div class="kpi-value">150+</div>
|
||
<div class="kpi-label">API 端点 · 3 种对接</div>
|
||
</div>
|
||
<div class="kpi-secondary glass">
|
||
<div class="kpi-value">${(ov.docs || state.docs || []).length || 0}</div>
|
||
<div class="kpi-label">知识文档 · 站内阅读</div>
|
||
</div>
|
||
<div class="kpi-secondary glass">
|
||
<div class="kpi-value">4</div>
|
||
<div class="kpi-label">控制通道 · 智能切换</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick Actions -->
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>快速操作</h2><p class="sub">一键进入核心功能</p></div>
|
||
<button class="btn soft" onclick="refreshAll()">🔄 刷新全部</button>
|
||
</div>
|
||
<div class="quick-grid">
|
||
<div class="quick-item" onclick="navigate('devices')"><div class="qi-icon" style="background:rgba(37,99,235,.1);color:var(--blue)">📱</div><div><div class="qi-text">设备管理</div><div class="qi-desc">${totalDevices} 台设备</div></div></div>
|
||
<div class="quick-item" onclick="navigate('engine')"><div class="qi-icon" style="background:rgba(124,58,237,.1);color:var(--purple)">🧠</div><div><div class="qi-text">智能引擎</div><div class="qi-desc">AI Brain · Hook</div></div></div>
|
||
<div class="quick-item" onclick="navigate('gateway')"><div class="qi-icon" style="background:rgba(14,165,166,.1);color:var(--teal)">🔗</div><div><div class="qi-text">对接网关</div><div class="qi-desc">API · MCP · OpenAI</div></div></div>
|
||
<div class="quick-item" onclick="navigate('docs')"><div class="qi-icon" style="background:rgba(22,163,74,.1);color:var(--green)">📖</div><div><div class="qi-text">知识库</div><div class="qi-desc">${(ov.docs || state.docs || []).length} 篇文档</div></div></div>
|
||
<div class="quick-item" onclick="scanLanDevices()"><div class="qi-icon" style="background:rgba(245,158,11,.1);color:var(--orange)">🔍</div><div><div class="qi-text">扫描局域网</div><div class="qi-desc">${state.discoveryStatus === 'scanning' ? '扫描中...' : discoveredCount ? discoveredCount + ' 个服务' : '自动发现'}</div></div></div>
|
||
<div class="quick-item" onclick="window.open('/docs','_blank')"><div class="qi-icon" style="background:rgba(220,38,38,.1);color:var(--red)">📄</div><div><div class="qi-text">Swagger 文档</div><div class="qi-desc">完整 API 参考</div></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Discovery Status (if scanned) -->
|
||
${state.discoveredServers?.length ? `
|
||
<div class="panel glass discovery-card">
|
||
<div class="panel-head">
|
||
<div><h2>🔍 局域网发现</h2><p class="sub">已发现 ${state.discoveredServers.length} 个 SDK 服务${state.serverInfo ? ` · 本机 ${state.serverInfo.ips?.[0] || 'localhost'}:${state.serverInfo.port}` : ''}</p></div>
|
||
<button class="btn soft" onclick="scanLanDevices()">重新扫描</button>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px">
|
||
${state.discoveredServers.map(s => `
|
||
<div class="card" style="${s.is_self ? 'border-color:var(--green);background:rgba(22,163,74,.04)' : ''}">
|
||
<h3>${s.is_self ? '🏠 本机服务器' : '🖥️ 发现的服务'}</h3>
|
||
<p><strong>${s.ip}:${s.port}</strong></p>
|
||
<p style="font-size:11px;color:var(--sub);margin-top:4px">
|
||
WS: ${esc(s.ws_url)}<br>
|
||
发现时间: ${s.discovered_at ? new Date(s.discovered_at).toLocaleTimeString() : '-'}
|
||
</p>
|
||
${!s.is_self ? `<button class="btn soft" style="margin-top:8px;font-size:11px" onclick="window.open('${s.url}','_blank')">打开控制台</button>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Capability Hub -->
|
||
<div class="panel glass capability-hub">
|
||
<div class="capability-hub-head">
|
||
<div>
|
||
<div class="capability-hub-kicker"><i></i> DEVICE CONTROL</div>
|
||
<div class="capability-hub-title">设备能力中心</div>
|
||
<div class="capability-hub-sub">按使用目的选择,不需要理解底层技术。</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:7px">
|
||
<span class="capability-hub-status">AI 自动编排</span>
|
||
<button class="btn soft" style="font-size:10px;padding:7px 10px" onclick="refreshConnectionModes()">刷新状态</button>
|
||
</div>
|
||
</div>
|
||
<div class="capability-grid">
|
||
${deviceControlCards(modes).map((m,index) => `<button class="capability-card" onclick="openControlCapability('${m.id}')">
|
||
<div class="capability-card-top"><span class="capability-icon">${m.icon}</span><span class="capability-index">0${index+1}</span></div>
|
||
<div class="capability-name">${m.name}</div>
|
||
<div class="capability-desc">${m.desc}</div>
|
||
<div class="capability-foot"><span class="capability-state ${m.source?.available?'ready':''}"><i></i>${m.status}</span><span class="capability-enter">进入 →</span></div>
|
||
</button>`).join('')}
|
||
</div>
|
||
<div class="capability-note">系统会自动选择最快可用通道:数据优先走 Hook,界面操作走 Agent,设备级操作走 ADB。</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>当前 APP</th></tr></thead><tbody>
|
||
${devices.length ? devices.slice(0,8).map(d => {
|
||
const isU = d.status==='unauthorized';
|
||
return `<tr onclick="${isU?'':`openDeviceDetail('${d.device_id}')`}" style="cursor:${isU?'default':'pointer'};${isU?'opacity:.6':''}">
|
||
<td><strong>${isU?'🔒 ':''}${esc(d.model||d.device_id||'-')}</strong></td>
|
||
<td>${deviceStatusLabel(d)}</td>
|
||
<td><div class="mode-badges">${isU?'<span class="mode-badge unavailable">⚠ 需授权</span>':((d.connection_modes||[]).filter(m=>m.available).slice(0,3).map(m=>`<span class="mode-badge available"><span class="badge-dot"></span>${esc(m.id)}</span>`).join('')||'ADB')}</div></td>
|
||
<td>${isU?'-':esc(d.current_app||'-')}</td></tr>`;
|
||
}).join('') : `<tr><td colspan="4" class="empty">暂无设备 · <a href="javascript:void(0)" onclick="navigate('devices')" style="color:var(--blue)">连接设备</a></td></tr>`}
|
||
</tbody></table></div>
|
||
</div>
|
||
${renderBindSection()}
|
||
`;
|
||
}
|
||
|
||
function renderOverviewClean(){
|
||
const ov = state.overview || {};
|
||
const devices = state.devices || ov.devices || [];
|
||
const total = ov.device_total ?? devices.length;
|
||
const online = ov.device_online ?? devices.filter(d=>['online','adb'].includes(d.status||d.adb_status)).length;
|
||
const ws = ov.ws_online ?? devices.filter(d=>d.status==='online').length;
|
||
const adb = ov.adb_count ?? devices.filter(deviceHasAdb).length;
|
||
const offline = Math.max(0, total-online);
|
||
const deviceRows = devices.slice(0,5).map(d=>`<button class="overview-device-row" onclick="openDeviceDetail('${esc(d.device_id)}')">
|
||
<span class="overview-device-icon">📱</span><span><b>${esc(d.model||d.nickname||d.device_id)}</b><small>${esc(d.device_id)}</small></span>
|
||
<i>${deviceStatusLabel(d)}</i><em>查看 →</em>
|
||
</button>`).join('') || '<div class="empty">当前没有已连接设备,可在右侧扫码绑定</div>';
|
||
return `
|
||
<section class="overview-hero glass">
|
||
<div><span class="section-eyebrow">工作手机运营中心</span><h2>数据总览</h2><p>设备、连接和服务状态集中查看,核心入口一步到达。</p></div>
|
||
<button class="btn primary" onclick="refreshAll()">🔄 刷新实时数据</button>
|
||
</section>
|
||
<section class="overview-metrics">
|
||
${[['设备总数',total,'📱','全部登记设备'],['在线设备',online,'●','当前可控制'],['WS / ADB',`${ws} / ${adb}`,'🔗','无线与直连'],['离线设备',offline,'○','等待设备上线']].map(([label,value,icon,desc])=>`<article class="overview-metric glass"><span>${icon}</span><div><small>${label}</small><strong>${value}</strong><p>${desc}</p></div></article>`).join('')}
|
||
</section>
|
||
<section class="overview-quick glass">
|
||
<div class="section-title"><div><span class="section-eyebrow">快捷入口</span><h3>常用操作</h3></div></div>
|
||
<div class="overview-quick-grid">
|
||
<button onclick="navigate('devices')"><i>📱</i><b>手机设备</b><small>选择手机进入控制</small></button>
|
||
<button onclick="scanLanDevices()"><i>🔍</i><b>扫描局域网</b><small>发现SDK与手机节点</small></button>
|
||
<button onclick="navigate('engine')"><i>🧠</i><b>智能引擎</b><small>AI、Hook与运行监控</small></button>
|
||
<button onclick="navigate('gateway')"><i>🔗</i><b>对接网关</b><small>REST、MCP与存客宝</small></button>
|
||
</div>
|
||
</section>
|
||
<section class="overview-columns">
|
||
<div class="panel glass"><div class="section-title"><div><span class="section-eyebrow">设备快照</span><h3>最近设备</h3></div><button class="btn soft" onclick="navigate('devices')">全部手机</button></div><div class="overview-device-list">${deviceRows}</div></div>
|
||
${renderBindSection()}
|
||
</section>`;
|
||
}
|
||
|
||
/* ═══ 1.5 WeChat Control Panel ═══ */
|
||
|
||
const WX_MODULES = {
|
||
message:{title:'消息管理',count:6,ch:'Hook优先(Frida RPC ~200ms) | u2降级(~5s)',actions:[
|
||
{n:'发送消息',a:'send_message',f:[{k:'to_id',l:'联系人'},{k:'content',l:'内容',t:'textarea'},{k:'msg_type',l:'类型',v:'text'}]},
|
||
{n:'获取消息',a:'get_messages',f:[{k:'conversation_id',l:'会话'},{k:'limit',l:'数量',v:'500'}]},
|
||
{n:'批量发送',a:'batch_send_message',f:[{k:'to_ids',l:'接收者(逗号分隔)'},{k:'content',l:'内容',t:'textarea'}]},
|
||
{n:'转发消息',a:'forward_message',f:[{k:'to_id',l:'转发给'}]},
|
||
{n:'撤回消息',a:'recall_message',f:[]},
|
||
{n:'发送名片',a:'send_card',f:[{k:'to_id',l:'发给谁'},{k:'card_wxid',l:'名片微信号'}]},
|
||
]},
|
||
friend:{title:'好友管理',count:8,ch:'Hook可读(SQLite ~100ms) | u2写(~5s)',actions:[
|
||
{n:'添加好友',a:'add_friend',f:[{k:'user_id',l:'微信号'},{k:'message',l:'验证消息'}]},
|
||
{n:'通过请求',a:'accept_friend',f:[{k:'user_id',l:'用户(可选)'}]},
|
||
{n:'设置备注',a:'set_remark',f:[{k:'user_id',l:'好友'},{k:'remark',l:'备注'}]},
|
||
{n:'删除好友',a:'delete_friend',f:[{k:'user_id',l:'好友'}]},
|
||
{n:'获取联系人',a:'get_contacts',f:[{k:'limit',l:'数量',v:'500'}]},
|
||
{n:'搜索联系人',a:'search_contact',f:[{k:'keyword',l:'关键词'}]},
|
||
{n:'好友详情',a:'get_friend_info',f:[{k:'user_id',l:'好友'}]},
|
||
{n:'批量添加',a:'batch_add_friend',f:[{k:'user_ids',l:'ID列表(逗号)'},{k:'message',l:'验证消息'}]},
|
||
]},
|
||
group:{title:'群聊管理',count:10,ch:'Hook可读(群/成员走SQLite) | u2写',actions:[
|
||
{n:'创建群聊',a:'create_group',f:[{k:'group_name',l:'群名'},{k:'member_ids',l:'成员(逗号)'}]},
|
||
{n:'邀请入群',a:'invite_to_group',f:[{k:'group_id',l:'群名'},{k:'member_ids',l:'邀请(逗号)'}]},
|
||
{n:'移出群聊',a:'remove_from_group',f:[{k:'group_id',l:'群名'},{k:'member_ids',l:'移除(逗号)'}]},
|
||
{n:'群公告',a:'set_group_notice',f:[{k:'group_id',l:'群名'},{k:'notice',l:'公告',t:'textarea'}]},
|
||
{n:'设置群名',a:'set_group_name',f:[{k:'group_id',l:'群名'},{k:'group_name',l:'新群名'}]},
|
||
{n:'群消息',a:'send_group_message',f:[{k:'group_id',l:'群名'},{k:'content',l:'内容',t:'textarea'}]},
|
||
{n:'群欢迎语',a:'set_group_welcome',f:[{k:'group_id',l:'群名'},{k:'welcome_text',l:'欢迎语'}]},
|
||
{n:'群列表',a:'get_groups',f:[{k:'limit',l:'数量',v:'100'}]},
|
||
{n:'群成员',a:'get_group_members',f:[{k:'group_id',l:'群名'}]},
|
||
{n:'退出群聊',a:'quit_group',f:[{k:'group_id',l:'群名'}]},
|
||
]},
|
||
moments:{title:'朋友圈',count:8,ch:'u2 UI自动化(~5-10s)',actions:[
|
||
{n:'发布朋友圈',a:'post_moments',f:[{k:'content',l:'文字',t:'textarea'}]},
|
||
{n:'点赞',a:'like_moments',f:[{k:'user_id',l:'好友'},{k:'post_index',l:'第几条',v:'0'}]},
|
||
{n:'评论',a:'comment_moments',f:[{k:'user_id',l:'好友'},{k:'comment',l:'评论'},{k:'post_index',l:'第几条',v:'0'}]},
|
||
{n:'获取朋友圈',a:'get_moments',f:[{k:'user_id',l:'好友(空=自己)'},{k:'limit',l:'数量',v:'10'}]},
|
||
{n:'删除',a:'delete_moments',f:[{k:'post_index',l:'第几条',v:'0'}]},
|
||
{n:'设置封面',a:'set_moments_cover',f:[]},
|
||
{n:'可见天数',a:'set_moments_privacy',f:[{k:'days',l:'天数',v:'180'}]},
|
||
{n:'分享链接',a:'forward_moments_link',f:[{k:'url',l:'URL'},{k:'title',l:'标题'}]},
|
||
]},
|
||
tag:{title:'标签管理',count:6,ch:'Hook可读(SQLite) | u2写',actions:[
|
||
{n:'创建标签',a:'create_tag',f:[{k:'tag_name',l:'标签名'}]},
|
||
{n:'删除标签',a:'delete_tag',f:[{k:'tag_name',l:'标签名'}]},
|
||
{n:'标签列表',a:'get_tags',f:[]},
|
||
{n:'打标签',a:'add_tag',f:[{k:'user_id',l:'好友'},{k:'tags',l:'标签(逗号)'}]},
|
||
{n:'移除标签',a:'remove_tag',f:[{k:'user_id',l:'好友'},{k:'tags',l:'标签(逗号)'}]},
|
||
{n:'按标签查人',a:'get_users_by_tag',f:[{k:'tag_name',l:'标签名'}]},
|
||
]},
|
||
profile:{title:'个人设置',count:6,ch:'Hook可读(SQLite ~50ms) | u2写(~6s)',actions:[
|
||
{n:'获取资料',a:'get_profile',f:[]},
|
||
{n:'修改昵称',a:'set_nickname',f:[{k:'nickname',l:'昵称'}]},
|
||
{n:'修改签名',a:'set_signature',f:[{k:'signature',l:'签名'}]},
|
||
{n:'修改头像',a:'set_avatar',f:[]},
|
||
{n:'设置性别',a:'set_gender',f:[{k:'gender',l:'性别(男/女)'}]},
|
||
{n:'设置地区',a:'set_region',f:[{k:'region',l:'地区(空格分隔)'}]},
|
||
]},
|
||
security:{title:'账号安全',count:9,ch:'u2 UI自动化(~5-10s)',actions:[
|
||
{n:'账号状态',a:'check_account_status',f:[]},
|
||
{n:'安全中心',a:'safety_center',f:[]},
|
||
{n:'微信解封',a:'unblock_account',f:[]},
|
||
{n:'自助解封',a:'unblock_self',f:[]},
|
||
{n:'申诉解封',a:'unblock_appeal',f:[]},
|
||
{n:'短信解封',a:'unblock_with_sms',f:[{k:'phone',l:'手机号'}]},
|
||
{n:'修改密码',a:'change_password',f:[]},
|
||
{n:'功能限制',a:'check_restrictions',f:[]},
|
||
{n:'申诉限制',a:'appeal_restriction',f:[]},
|
||
]},
|
||
payment:{title:'支付',count:7,ch:'u2(安全考虑不走Hook, ~5-10s)',actions:[
|
||
{n:'发红包',a:'send_red_packet',f:[{k:'to_id',l:'发给谁'},{k:'amount',l:'金额'}]},
|
||
{n:'转账',a:'transfer',f:[{k:'to_id',l:'转给谁'},{k:'amount',l:'金额'}]},
|
||
{n:'付款码',a:'show_payment_code',f:[]},
|
||
{n:'收款',a:'receive_payment',f:[]},
|
||
{n:'钱包',a:'view_wallet',f:[]},
|
||
{n:'账单',a:'view_transactions',f:[]},
|
||
{n:'领红包',a:'receive_red_packet',f:[]},
|
||
]},
|
||
more:{title:'更多功能',count:27,ch:'u2 UI自动化',actions:[
|
||
{n:'视频号',a:'open_video_channel',f:[]},
|
||
{n:'点赞视频',a:'like_video',f:[{k:'index',l:'第几个',v:'0'}]},
|
||
{n:'扫一扫',a:'scan_qr_code',f:[]},
|
||
{n:'我的二维码',a:'show_my_qr',f:[]},
|
||
{n:'置顶聊天',a:'set_chat_top',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'免打扰',a:'set_mute_chat',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'清空记录',a:'clear_chat_history',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'收藏列表',a:'get_favorites',f:[]},
|
||
{n:'打开小程序',a:'open_mini_program',f:[{k:'name',l:'名称'}]},
|
||
{n:'关注公众号',a:'follow_official_account',f:[{k:'account_name',l:'公众号名'}]},
|
||
{n:'语音通话',a:'voice_call',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'视频通话',a:'video_call',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'群发消息',a:'mass_send',f:[{k:'user_ids',l:'接收者(逗号)'},{k:'content',l:'内容',t:'textarea'}]},
|
||
{n:'搜一搜',a:'wechat_search',f:[{k:'keyword',l:'关键词'}]},
|
||
{n:'看一看',a:'top_stories',f:[]},
|
||
{n:'获取步数',a:'get_steps',f:[]},
|
||
{n:'点赞步数',a:'like_steps',f:[{k:'user_id',l:'好友'}]},
|
||
{n:'发送位置',a:'send_location',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'实时位置',a:'share_real_time_location',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'发送表情',a:'send_emoji',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'表情列表',a:'get_sticker_list',f:[]},
|
||
{n:'语音消息',a:'send_voice_message',f:[{k:'user_id',l:'联系人'},{k:'duration',l:'秒数',v:'3'}]},
|
||
{n:'发送文件',a:'send_file_from_chat',f:[{k:'user_id',l:'联系人'}]},
|
||
{n:'勿扰模式',a:'toggle_do_not_disturb',f:[]},
|
||
{n:'清理缓存',a:'clear_cache',f:[]},
|
||
{n:'退出登录',a:'logout',f:[]},
|
||
{n:'切换账号',a:'switch_account',f:[]},
|
||
]},
|
||
};
|
||
|
||
const _wxStats = {total:0,success:0,totalTime:0};
|
||
const _wxLogs = [];
|
||
let _wxModule = 'message';
|
||
function wxStatusTone(device){
|
||
const st = device?.status || device?.adb_status || 'offline';
|
||
if (st === 'online') return {label:'WS在线', color:'var(--green)'};
|
||
if (st === 'adb') return {label:'ADB可控', color:'var(--blue)'};
|
||
if (st === 'unauthorized') return {label:'待授权', color:'var(--orange)'};
|
||
return {label:'离线', color:'var(--red)'};
|
||
}
|
||
|
||
function wxMapValue(device, ...keys){
|
||
const profile = device?.device_profile || device?.profile || {};
|
||
for (const source of [device || {}, profile || {}]) {
|
||
for (const key of keys) {
|
||
if (source[key] !== undefined && source[key] !== null && source[key] !== '') return source[key];
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function wxDisplayTime(value){
|
||
if (!value) return '待现场核对';
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', {hour12:false});
|
||
}
|
||
|
||
function wxDeviceMap(device){
|
||
return {
|
||
wxid: wxMapValue(device, 'wxid', 'wx_id', 'wechat_wxid'),
|
||
wechatId: wxMapValue(device, 'wechat_id', 'wechatId', 'account_wechat_id'),
|
||
friendCount: wxMapValue(device, 'friend_count', 'friends_count', 'contact_count', 'totalFriend', 'total_friend'),
|
||
lastActive: wxMapValue(device, 'last_active', 'last_seen', 'last_activity_at', 'last_heartbeat', 'updated_at'),
|
||
};
|
||
}
|
||
|
||
function deviceHasAdb(device){
|
||
return !!device && (
|
||
device.status==='adb' ||
|
||
device.adb_status==='device' ||
|
||
device.connection_type==='adb' ||
|
||
device.best_mode?.id==='adb' ||
|
||
(device.connection_modes||[]).some(mode=>mode.id==='adb' && mode.available)
|
||
);
|
||
}
|
||
|
||
function wxHealthScore(device){
|
||
if (!device) return 0;
|
||
let score = 35;
|
||
const st = device.status || device.adb_status || '';
|
||
if (st === 'online') score += 35;
|
||
else if (st === 'adb') score += 28;
|
||
if (device.wechat_running || device.current_app === 'com.tencent.mm') score += 18;
|
||
if (device.last_heartbeat) score += 12;
|
||
return Math.min(score, 100);
|
||
}
|
||
|
||
function selectWechatWorkspaceDevice(deviceId){
|
||
state.currentDevice = deviceId;
|
||
renderCurrentView(false);
|
||
loadDeviceDetail(deviceId);
|
||
}
|
||
|
||
function wxReadInput(id){
|
||
const el = document.getElementById(id);
|
||
return el ? String(el.value || '').trim() : '';
|
||
}
|
||
|
||
function wxCompactResult(data){
|
||
const payload = data?.data && typeof data.data === 'object' ? data.data : data;
|
||
const summary = {};
|
||
['count','returned_count','total_count','raw_total_count','has_more','offset','channel','action_resolved'].forEach(key => {
|
||
if (payload?.[key] !== undefined) summary[key] = payload[key];
|
||
});
|
||
['contacts','messages','groups','labels','tags','moments'].forEach(key => {
|
||
if (Array.isArray(payload?.[key])) summary[`${key}_returned_count`] = payload[key].length;
|
||
});
|
||
const display = Object.keys(summary).length ? {summary, response:data} : data;
|
||
return JSON.stringify(display, (_key, value) => {
|
||
if (Array.isArray(value) && value.length > 20) {
|
||
return {returned_count:value.length, preview:value.slice(0, 10)};
|
||
}
|
||
return value;
|
||
}, 2);
|
||
}
|
||
|
||
function wxResultHtml(data){
|
||
const payload = data?.data && typeof data.data === 'object' ? data.data : data || {};
|
||
const source = data?.channel_used || payload.channel || 'sdk_control';
|
||
const profileKeys = [
|
||
['wxid','微信 ID'],['nickname','昵称'],['remark','备注'],['wechat_version','微信版本'],
|
||
['wechat_running','运行状态'],['installed','已安装']
|
||
];
|
||
const profileItems = profileKeys.filter(([key]) => payload[key] !== undefined && payload[key] !== null && payload[key] !== '')
|
||
.map(([key,label]) => `<div class="wx-result-item"><small>${label}</small><b>${esc(payload[key] === true ? '正常' : payload[key] === false ? '否' : String(payload[key]))}</b></div>`).join('');
|
||
if(profileItems){
|
||
return `<div class="wx-result-card"><div class="wx-result-head"><span>资料已获取</span><span class="wx-result-badge">成功</span></div><div class="wx-result-grid">${profileItems}</div></div>`;
|
||
}
|
||
const listKey = ['contacts','groups','labels','tags','moments'].find(key => Array.isArray(payload[key]));
|
||
if(listKey){
|
||
const list = payload[listKey] || [];
|
||
if(!list.length) return `<div class="wx-result-card"><div class="wx-result-head"><span>${listKey==='contacts'?'联系人':listKey==='groups'?'群聊':'数据'}查询</span><span class="wx-result-badge">已完成</span></div><div class="wx-result-empty">当前没有返回可展示的${listKey==='contacts'?'联系人':listKey==='groups'?'群聊':'记录'}。</div></div>`;
|
||
return `<div class="wx-result-card"><div class="wx-result-head"><span>${listKey==='contacts'?'联系人':listKey==='groups'?'群聊':'查询结果'}(${list.length})</span><span class="wx-result-badge">成功</span></div><div class="wx-result-list">${list.slice(0,20).map(item=>{const name=item.nickname||item.remark||item.name||item.user_id||item.wxid||'未命名';const sub=item.wxid||item.user_id||item.username||'';return `<div class="wx-result-row"><b>${esc(String(name))}</b><small>${esc(String(sub))}</small></div>`}).join('')}</div></div>`;
|
||
}
|
||
if(payload.ui_tree){
|
||
const tree = String(payload.ui_tree).trim();
|
||
return `<div class="wx-result-card"><div class="wx-result-head"><span>微信界面已读取</span><span class="wx-result-badge">成功</span></div>${tree && tree!=='null'?`<div class="wx-result-empty" style="white-space:pre-wrap;max-height:150px;overflow:auto">${esc(tree.slice(0,2000))}</div>`:'<div class="wx-result-empty">已连接,当前页面没有可读的微信节点。</div>'}</div>`;
|
||
}
|
||
const simple = Object.entries(payload).filter(([key,value])=>!['raw','response','data'].includes(key) && ['string','number','boolean'].includes(typeof value)).slice(0,8);
|
||
if(simple.length) return `<div class="wx-result-card"><div class="wx-result-head"><span>操作已完成</span><span class="wx-result-badge">成功</span></div><div class="wx-result-grid">${simple.map(([key,value])=>`<div class="wx-result-item"><small>${esc(key)}</small><b>${esc(String(value))}</b></div>`).join('')}</div></div>`;
|
||
return `<div class="wx-result-card"><div class="wx-result-head"><span>操作已完成</span><span class="wx-result-badge">成功</span></div></div>`;
|
||
}
|
||
|
||
|
||
function renderUnifiedControl(showFleet=false){
|
||
const allDevices = state.devices || [];
|
||
if (!state.aiConfig && !state.aiConfigLoading) setTimeout(loadAIConfig, 0);
|
||
const did = state.currentDevice || (allDevices[0]?.device_id);
|
||
if (did && !state.currentDevice) {
|
||
state.currentDevice = did;
|
||
setTimeout(()=>loadDeviceDetail(did), 0);
|
||
}
|
||
const current = allDevices.find(d=>d.device_id===did) || allDevices[0];
|
||
const currentMap = wxDeviceMap(current);
|
||
const currentRealtime = (state.realtime?.devices || []).find(d => d.device_id === did) || {};
|
||
const lastRealtimeEvent = (state.realtime?.recent_events || []).find(e => e.device_id === did || e.payload?.device_id === did);
|
||
const keyword = (state.wxKeyword || '').toLowerCase();
|
||
const filter = state.wxStatusFilter || 'all';
|
||
const filtered = allDevices.filter(d => {
|
||
const src = [d.device_id,d.model,d.brand,d.nickname,d.profile_nickname].filter(Boolean).join(' ').toLowerCase();
|
||
if (keyword && !src.includes(keyword)) return false;
|
||
if (filter==='online' && d.status!=='online') return false;
|
||
if (filter==='adb' && !deviceHasAdb(d)) return false;
|
||
if (filter==='offline' && !['offline','unauthorized'].includes(d.status||d.adb_status||'offline')) return false;
|
||
return true;
|
||
});
|
||
const onlineCount = allDevices.filter(d=>['online','adb'].includes(d.status||d.adb_status)).length;
|
||
const wxRunning = allDevices.filter(d=>d.wechat_running||d.current_app==='com.tencent.mm').length;
|
||
const tone = wxStatusTone(current);
|
||
const hp = wxHealthScore(current);
|
||
const hpColor = hp>=80?'var(--green)':hp>=50?'var(--orange)':'var(--red)';
|
||
const hasDevice = !!current && current.controllable !== false;
|
||
const unauth = allDevices.filter(d=>d.status==='unauthorized'||d.adb_status==='unauthorized');
|
||
const appTab = state.appFocus || 'wechat';
|
||
const logs = state.logs.map(item => `<div class="${item.type==='error'?'log-error':'log-info'}">[${item.ts}] ${esc(item.message)}</div>`).join('');
|
||
const executionLogHtml = _wxLogs.slice(-15).reverse().map(l=>
|
||
`<div style="padding:3px 0;font-size:11px;border-bottom:1px solid var(--border)"><span style="color:var(--blue)">${l.ts}</span> <b>${esc(l.action)}</b> <span style="color:${l.ok?'var(--green)':'var(--red)'};">${l.ok?'\u2713':'\u2717'}</span> <span style="color:var(--sub)">${l.ch} ${l.ms}ms</span></div>`
|
||
).join('');
|
||
|
||
const deviceCards = filtered.length ? filtered.map(device => {
|
||
const st = wxStatusTone(device);
|
||
const active = device.device_id === did;
|
||
const name = device.profile_nickname || device.nickname || device.model || device.device_id;
|
||
const map = wxDeviceMap(device);
|
||
const wxId = map.wxid || map.wechatId;
|
||
const wxRunningNow = device.wechat_running || device.current_app === 'com.tencent.mm';
|
||
const wxDisplay = wxId || device.wechat_version || (wxRunningNow ? '运行中' : '-');
|
||
const avatar = name ? name[0] : '\ud83d\udcf1';
|
||
return `<div onclick="selectDevice('${device.device_id}')" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;border:1.5px solid ${active?'rgba(37,99,235,.4)':'var(--border)'};background:${active?'rgba(37,99,235,.04)':'rgba(255,255,255,.85)'};cursor:pointer;margin-bottom:6px;transition:all .2s">
|
||
<div style="width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,${st.color}22,${st.color}08);display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:700;color:${st.color};flex-shrink:0">${esc(avatar)}</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<span style="font-size:12px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</span>
|
||
<span style="font-size:9px;font-weight:700;color:#fff;padding:2px 7px;border-radius:20px;background:${st.color};flex-shrink:0">${st.label}</span>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--sub);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(device.device_id)}</div>
|
||
<div style="font-size:9px;color:var(--muted);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">IMEI: ${esc(device.imei || '待登记')}</div>
|
||
<div style="display:flex;gap:8px;margin-top:2px;font-size:10px;color:var(--muted)">
|
||
<span>微信: ${esc(wxDisplay)}</span>
|
||
<span>微信ID: ${esc(wxId || '待现场核对')}</span>
|
||
<span>好友: ${esc(map.friendCount === '' ? '待现场核对' : String(map.friendCount))}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('') : renderConnectGuide();
|
||
|
||
const filterBtns = ['all','online','adb','offline'].map(f =>
|
||
`<button class="btn ${state.wxStatusFilter===f?'primary':'soft'}" style="font-size:10px;padding:4px 10px" onclick="state.wxStatusFilter='${f}';state.wxKeyword='';renderCurrentView(false)">${{all:'全部 '+allDevices.length,online:'在线',adb:'ADB',offline:'离线'}[f]}</button>`
|
||
).join('');
|
||
|
||
const deviceInfoCard = current ? `
|
||
<div class="device-summary-main">
|
||
<div class="device-summary-icon" style="background:linear-gradient(135deg,${tone.color},${tone.color}88)">📱</div>
|
||
<div class="device-summary-copy">
|
||
<div class="device-summary-name" id="device-summary-name">${esc(current.profile_nickname||current.nickname||current.model||current.device_id)}</div>
|
||
<div class="device-summary-id" id="device-summary-id">${esc([current.brand,current.model].filter(Boolean).join(' ')||'Android')} · ${esc(current.device_id)}</div>
|
||
</div>
|
||
<div class="device-summary-state">
|
||
<span style="font-size:9px;font-weight:750;color:#fff;padding:3px 8px;border-radius:20px;background:${tone.color}">${tone.label}</span>
|
||
<div style="font-size:9px;color:var(--sub);margin-top:3px">健康 <b id="device-summary-health" style="color:${hpColor}">${hp}%</b></div>
|
||
</div>
|
||
</div>
|
||
<div class="device-metric-strip">
|
||
<div class="device-mini-metric"><small>系统</small><b id="device-metric-android">${esc(current.android_version||current.sdk_version||'-')}</b></div>
|
||
<div class="device-mini-metric"><small>当前应用</small><b id="device-metric-app">${esc(current.current_app||(current.wechat_running?'微信':'-'))}</b></div>
|
||
<div class="device-mini-metric"><small>电量</small><b id="device-metric-battery">${current.battery_level>=0?current.battery_level+'%'+(current.battery_charging?' ⚡':''):'-'}</b></div>
|
||
<div class="device-mini-metric"><small>连接</small><b id="device-metric-online">${currentRealtime.online||current.status==='online'?'在线':'可控'}</b></div>
|
||
</div>
|
||
<details class="compact-device-more">
|
||
<summary>连接、账号与设备详情</summary>
|
||
<div class="compact-device-body">
|
||
${(current.connection_modes||[]).length ? '<div style="display:flex;gap:4px;flex-wrap:wrap">'+(current.connection_modes||[]).map(m=>'<span class="mode-badge '+(m.available?(current.best_mode?.id===m.id?'best':'available'):'unavailable')+'"><span class="badge-dot"></span>'+esc(m.name||m.id)+'</span>').join('')+'</div>' : ''}
|
||
<div class="compact-meta-grid">
|
||
<div class="compact-meta-item"><small>微信 wxid</small><b>${esc(currentMap.wxid || '待核对')}</b></div>
|
||
<div class="compact-meta-item"><small>好友数</small><b>${esc(currentMap.friendCount === '' ? '待核对' : String(currentMap.friendCount))}</b></div>
|
||
<div class="compact-meta-item"><small>最后活跃</small><b>${esc(wxDisplayTime(currentMap.lastActive))}</b></div>
|
||
<div class="compact-meta-item"><small>IMEI</small><b>${esc(current.imei || '待登记')}</b></div>
|
||
<div class="compact-meta-item"><small>内存</small><b>${current.memory_usage_pct>0?current.memory_usage_pct.toFixed(1)+'%':'-'}</b></div>
|
||
<div class="compact-meta-item"><small>最近事件</small><b>${esc(lastRealtimeEvent?.event_type || '暂无')}</b></div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
` : '<div style="text-align:center;padding:20px;color:var(--muted)">请选择设备</div>';
|
||
|
||
const deviceActions = `
|
||
<div class="phone-console-card" style="${!hasDevice?'opacity:.5;pointer-events:none':''}">
|
||
<div class="phone-console-head"><div><div class="phone-console-title">📱 手机控制</div><div class="phone-console-sub">常用操作放在这里,点击即可执行</div></div><span class="phone-console-state"><i></i>${current?.wechat_running?'微信运行中':'设备在线'}</span></div>
|
||
<div class="phone-primary-actions">
|
||
<button class="phone-primary-action" onclick="doAction('open_wechat')"><b>💬</b>打开微信</button>
|
||
<button class="phone-primary-action" onclick="doAction('recent')"><b>▣</b>最近任务</button>
|
||
<button class="phone-primary-action" onclick="doAction('home')"><b>🏠</b>主页</button>
|
||
<button class="phone-primary-action" onclick="doAction('back')"><b>↩️</b>返回</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
const serviceSummary = Array.isArray(current?.service_summary) ? current.service_summary : [];
|
||
const serviceSummaryHtml = serviceSummary.length ? serviceSummary.map(service => `<span class="service-chip ${service.running?'ready':''}"><i></i>${esc(service.name)} · ${service.running?'运行中':'待配置'}</span>`).join('') : '<span style="font-size:10px;color:var(--muted)">服务状态读取中</span>';
|
||
const deviceManagementCard = `
|
||
<details class="control-section">
|
||
<summary><span>🔗 设备操作与服务</span><span class="control-section-kicker">${esc(current?.status==='online'?'设备在线':'可控设备')} · 点击展开</span></summary>
|
||
<div class="control-section-body">
|
||
<div class="phone-console-head"><div><div class="phone-console-title">常用设备操作</div><div class="phone-console-sub">安装、重启、服务检测统一放在这里。</div></div></div>
|
||
<div class="device-admin-actions">
|
||
<button class="device-admin-btn" onclick="deviceAdminAction('reboot')"><b>🔄</b><span>重启手机</span></button>
|
||
<button class="device-admin-btn" onclick="chooseDeviceApk()"><b>📦</b><span>安装软件</span></button>
|
||
<button class="device-admin-btn" onclick="chooseDeviceApk('wechat')"><b>💬</b><span>安装微信</span></button>
|
||
<button class="device-admin-btn" onclick="loadDeviceServices('${esc(did)}')"><b>⚡</b><span>检测服务</span></button>
|
||
</div>
|
||
<input id="device-apk-file" type="file" accept=".apk" style="display:none" onchange="installSelectedApk(this.files[0])">
|
||
<div class="service-summary-row">${serviceSummaryHtml}</div>
|
||
</div>
|
||
</details>`;
|
||
|
||
const runtimeConfigCard = current ? `
|
||
<div class="general-settings-body">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||
<div><div style="font-size:12px;font-weight:700">⚙ APK 运行参数</div><div style="font-size:10px;color:var(--sub);margin-top:2px">通过 SDK 热更新,手机无需重装</div></div>
|
||
<span style="font-size:10px;color:${current.wechat_running?'var(--green)':'var(--orange)'};font-weight:700">${current.wechat_running?'● 微信正在运行':'○ 微信未运行'}</span>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1.4fr .8fr;gap:8px;margin-bottom:8px">
|
||
<div><label style="display:block;font-size:10px;color:var(--sub);margin-bottom:4px">设备 IMEI</label><input id="runtime-imei" value="${esc(current.imei||'')}" placeholder="输入14~17位IMEI" maxlength="17" style="width:100%;padding:8px 10px;border-radius:9px;border:1px solid var(--border);font-size:11px;background:#fff"></div>
|
||
<div><label style="display:block;font-size:10px;color:var(--sub);margin-bottom:4px">检测周期</label><select id="runtime-wx-interval" style="width:100%;padding:8px 10px;border-radius:9px;border:1px solid var(--border);font-size:11px;background:#fff"><option value="30" ${Number(current.wechat_keepalive_interval_sec)===30?'selected':''}>30秒</option><option value="60" ${Number(current.wechat_keepalive_interval_sec)===60?'selected':''}>1分钟</option><option value="300" ${![30,60,600,1800].includes(Number(current.wechat_keepalive_interval_sec))?'selected':''}>5分钟</option><option value="600" ${Number(current.wechat_keepalive_interval_sec)===600?'selected':''}>10分钟</option><option value="1800" ${Number(current.wechat_keepalive_interval_sec)===1800?'selected':''}>30分钟</option></select></div>
|
||
</div>
|
||
<label style="display:flex;align-items:center;gap:7px;font-size:11px;color:var(--text);margin-bottom:10px"><input id="runtime-wx-enabled" type="checkbox" ${current.wechat_keepalive_enabled!==false?'checked':''}> 微信未运行时自动启动</label>
|
||
<div style="display:flex;gap:6px"><button class="btn primary" style="font-size:10px" onclick="saveDeviceRuntimeConfig()">保存到手机</button><button class="btn soft" style="font-size:10px" onclick="doAction('open_wechat')">立即启动微信</button></div>
|
||
</div>` : '';
|
||
|
||
const appMeta = {
|
||
wechat:{icon:'\ud83d\udcac',name:'微信',desc:'基础操作 · 数据 · 账号'},
|
||
soul:{icon:'\ud83d\udc9c',name:'Soul',desc:'打开 · 截图 · 设备操作'},
|
||
xhs:{icon:'\ud83d\udcd5',name:'小红书',desc:'打开 · 截图 · 设备操作'},
|
||
douyin:{icon:'\ud83c\udfb5',name:'抖音',desc:'打开 · 截图 · 设备操作'},
|
||
xianyu:{icon:'\ud83d\udc1f',name:'闲鱼',desc:'打开 · 截图 · 设备操作'},
|
||
};
|
||
const selectedApp = appMeta[appTab] || appMeta.wechat;
|
||
const controlButton = (icon,label,action) => `<button class="app-action-btn" onclick="${action}"><span class="icon">${icon}</span><span>${label}</span></button>`;
|
||
const appSelector = `
|
||
<div style="margin-top:16px">
|
||
<div class="panel-head" style="margin-bottom:12px">
|
||
<div><div style="font-size:13px;font-weight:750;color:var(--text)">📱 应用控制</div><div class="app-control-panel-sub">先进入一个应用,功能按需展开。</div></div>
|
||
<span class="pill">${hasDevice ? '已绑定设备' : '请选择可控设备'}</span>
|
||
</div>
|
||
<div class="${!hasDevice?'app-disabled':''}">
|
||
<div class="app-entry-focus">
|
||
<span class="app-control-icon">${selectedApp.icon}</span>
|
||
<span><span class="app-control-name">进入${selectedApp.name}控制</span><span class="app-control-desc">${appTab==='wechat'?'常用操作、资料查询、账号状态都在这里。':'设备级操作独立管理。'}</span></span>
|
||
<button class="btn primary" onclick="document.getElementById('app-control-panel')?.scrollIntoView({behavior:'smooth',block:'start'})">进入控制</button>
|
||
</div>
|
||
<div class="app-entry-nav">
|
||
${Object.entries(appMeta).filter(([id])=>id!==appTab).map(([id,meta])=>`
|
||
<button class="app-control-card" onclick="state.appFocus='${id}';renderCurrentView(false)">
|
||
<span class="app-control-icon">${meta.icon}</span><span class="app-control-name">${meta.name}</span>
|
||
</button>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div id="app-control-panel" class="app-control-panel ${!hasDevice?'app-disabled':''}">
|
||
<div class="app-control-panel-head">
|
||
<div><div class="app-control-panel-title">${selectedApp.icon} ${selectedApp.name}</div><div class="app-control-panel-sub">${appTab==='wechat'?'常用功能优先,更多功能点开查看。':'设备操作单独管理。'}</div></div>
|
||
<span class="mode-badge ${current?.wechat_running || current?.current_app==='com.tencent.mm' ? 'available':'unavailable'}"><span class="badge-dot"></span>${appTab==='wechat'?(current?.wechat_running?'微信运行中':'微信待启动'):'设备操作'}</span>
|
||
</div>
|
||
${appTab==='wechat' ? `
|
||
<details class="app-fold" open><summary>⚡ 基础操作</summary><div class="app-fold-body"><div class="app-action-grid">
|
||
${controlButton('📱','打开微信',"doAction('open_wechat')")}
|
||
${controlButton('📸','截屏',"doAction('screenshot')")}
|
||
${controlButton('🏠','回到主页',"doAction('home')")}
|
||
${controlButton('↩️','返回上级',"doAction('back')")}
|
||
</div></div></details>
|
||
<details class="app-fold"><summary>👤 微信数据</summary><div class="app-fold-body"><div class="app-action-grid">
|
||
${controlButton('👤','个人资料',"wxQuickRun('get_profile')")}
|
||
${controlButton('👥','联系人',"wxQuickRun('get_contacts',{limit:500})")}
|
||
${controlButton('🏷️','标签',"wxQuickRun('get_tags')")}
|
||
${controlButton('群','群列表',"wxQuickRun('get_groups',{limit:500})")}
|
||
${controlButton('🔔','未读消息',"wxQuickRun('get_unread_messages')")}
|
||
${controlButton('🔎','搜索微信',"wxQuickRun('wechat_search',{keyword:prompt('搜索关键词')||''})")}
|
||
</div></div></details>
|
||
<details class="app-fold"><summary>✨ 微信常用功能</summary><div class="app-fold-body"><div class="app-action-grid">
|
||
${controlButton('📸','发朋友圈',"wxCatalogRun('moments','post_moments')")}
|
||
${controlButton('🧧','发红包',"wxCatalogRun('payment','send_red_packet')")}
|
||
${controlButton('🧧','收红包',"wxCatalogRun('payment','receive_red_packet')")}
|
||
${controlButton('🔎','查询好友',"wxCatalogRun('friend','get_friend_info')")}
|
||
${controlButton('🏷️','按标签查人',"wxCatalogRun('tag','get_users_by_tag')")}
|
||
</div></div></details>
|
||
<details class="app-fold"><summary>🧰 微信功能目录</summary><div class="app-fold-body"><div class="hint" style="margin:0 0 8px">按模块查看当前已登记动作,带参数的功能点击后填写参数。</div>${renderWxActionCatalog()}</div></details>
|
||
<details class="app-fold"><summary>🧪 测试与诊断</summary><div class="app-fold-body"><div class="app-action-grid">
|
||
${controlButton('🧩','Hook 状态',"wxQuickRun('get_hook_status')")}
|
||
${controlButton('🏷️','微信版本',"wxQuickRun('get_wechat_version')")}
|
||
${controlButton('🔐','账号状态',"wxQuickRun('check_account_status')")}
|
||
${controlButton('🖼️','读取 UI 树',"doAction('ui_tree')")}
|
||
</div><div class="hint">测试结果统一显示在右侧“执行结果”和“操作日志”,不与日常功能混排。</div></div></details>
|
||
` : `
|
||
<details class="app-fold" open><summary>⚡ 基础操作</summary><div class="app-fold-body"><div class="app-action-grid">
|
||
${controlButton(selectedApp.icon,'打开'+selectedApp.name,`doAction('open_${appTab}')`)}
|
||
${controlButton('📸','截屏',"doAction('screenshot')")}
|
||
${controlButton('🏠','回到主页',"doAction('home')")}
|
||
${controlButton('↩️','返回上级',"doAction('back')")}
|
||
</div></div></details>
|
||
<div class="hint">${selectedApp.name} 的业务动作会继续放在本应用面板内,避免和微信、设备、测试功能混在一起。</div>
|
||
`}
|
||
</div>
|
||
</div>`;
|
||
|
||
const msgSection = appTab==='wechat' ? `
|
||
<details class="app-fold" style="margin-top:12px"><summary>💬 微信消息操作</summary>
|
||
<div class="app-fold-body">
|
||
<div style="display:flex;gap:6px;margin-bottom:6px"><input id="wx_quick_to" placeholder="发送给(昵称 / 群名 / wxid)" style="flex:1;padding:8px 10px;border-radius:10px;border:1px solid var(--border);font-size:11px;background:rgba(255,255,255,.92)"></div>
|
||
<div style="display:flex;gap:6px"><textarea id="wx_quick_msg" placeholder="消息内容" style="flex:1;min-height:54px;padding:8px 10px;border-radius:10px;border:1px solid var(--border);font-size:11px;resize:vertical;background:rgba(255,255,255,.92)"></textarea></div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
|
||
<button class="btn primary" style="font-size:10px;padding:6px 12px" onclick="wxQuickRun('send_message',{to_id:'wx_quick_to',content:'wx_quick_msg',msg_type:'text'})">发送消息</button>
|
||
<button class="btn soft" style="font-size:10px;padding:6px 12px" onclick="wxQuickRun('mass_send',{content:'wx_quick_msg'})">批量发送</button>
|
||
<button class="btn soft" style="font-size:10px;padding:6px 12px" onclick="wxQuickRun('add_friend',{user_id:wxReadInput('wx_quick_to'),message:wxReadInput('wx_quick_msg')})">添加好友</button>
|
||
</div>
|
||
</div>
|
||
</details>` : '';
|
||
|
||
const aiSection = `
|
||
<details class="app-fold" style="margin-top:12px"><summary>🤖 AI 指令</summary>
|
||
<div class="app-fold-body">
|
||
<details class="app-fold" style="margin:0 0 10px"><summary>🔧 AI 接口 <span class="pill">${state.aiConfig?.provider || '龙虾U盘'}</span></summary>
|
||
<div class="app-fold-body">
|
||
<div style="display:grid;grid-template-columns:1.5fr .8fr;gap:7px;margin-bottom:7px">
|
||
<input id="ai-config-base" value="${esc(state.aiConfig?.base_url || 'https://simpleapi.quwanzhi.com/v1')}" placeholder="接口地址,例如 https://simpleapi.quwanzhi.com/v1" style="width:100%;padding:8px 10px;border-radius:9px;border:1px solid var(--border);font-size:11px;background:#fff">
|
||
<input id="ai-config-model" value="${esc(state.aiConfig?.model || 'deepseek-chat')}" placeholder="模型名称" style="width:100%;padding:8px 10px;border-radius:9px;border:1px solid var(--border);font-size:11px;background:#fff">
|
||
</div>
|
||
<div style="display:flex;gap:7px;align-items:center"><input id="ai-config-key" type="password" placeholder="API Key(${state.aiConfig?.api_key || '待配置'})" style="flex:1;padding:8px 10px;border-radius:9px;border:1px solid var(--border);font-size:11px;background:#fff"><button class="btn primary" style="font-size:10px;padding:7px 12px" onclick="saveAIConfig()">保存接口</button></div>
|
||
<div id="ai-config-tip" style="font-size:10px;color:var(--sub);margin-top:6px">Agent、AI指令和设备自动化统一使用这里的接口。</div>
|
||
</div>
|
||
</details>
|
||
<div style="display:flex;gap:4px;margin-bottom:8px">${['pattern','ai','ai-only'].map(m=>`<button class="btn ${state.cmdMode===m?'primary':'soft'}" style="font-size:10px;padding:5px 10px" onclick="state.cmdMode='${m}';renderCurrentView(false)">${{pattern:'模式匹配',ai:'AI 执行','ai-only':'AI 解析'}[m]}</button>`).join('')}</div>
|
||
<div class="command-area" style="display:flex;gap:6px"><input id="cmd-input" placeholder="${esc(cmdPlaceholder())}" style="flex:1;padding:9px 10px;border-radius:10px;border:1px solid var(--border);font-size:12px;background:rgba(255,255,255,.92)" onkeydown="if(event.key==='Enter')sendCmd()"><button class="btn primary" style="font-size:11px;padding:7px 14px" onclick="sendCmd()">执行</button></div>
|
||
</div>
|
||
</details>`;
|
||
|
||
const compactAppSelector = `
|
||
<details class="control-section" open>
|
||
<summary><span>📱 应用控制</span><span class="control-section-kicker">${selectedApp.name} · 点击切换</span></summary>
|
||
<div class="control-section-body">
|
||
<div class="app-entry-nav" style="grid-template-columns:repeat(5,minmax(0,1fr));margin-bottom:12px">
|
||
${Object.entries(appMeta).map(([id,meta])=>`<button class="app-control-card ${appTab===id?'active':''}" onclick="state.appFocus='${id}';renderCurrentView(false)"><span class="app-control-icon">${meta.icon}</span><span class="app-control-name">${meta.name}</span></button>`).join('')}
|
||
</div>
|
||
${appTab==='wechat' ? `
|
||
<div class="app-action-grid" style="grid-template-columns:repeat(4,minmax(0,1fr))">
|
||
${controlButton('👤','个人资料',"wxQuickRun('get_profile')")}
|
||
${controlButton('👥','联系人',"wxQuickRun('get_contacts',{limit:500})")}
|
||
${controlButton('🔎','搜索好友',"wxCatalogRun('friend','search_contact')")}
|
||
${controlButton('📸','发朋友圈',"wxCatalogRun('moments','post_moments')")}
|
||
</div>
|
||
<details class="phone-more" style="margin-top:10px"><summary>更多微信功能</summary><div class="phone-more-body">
|
||
<div class="app-action-grid">
|
||
${controlButton('🏷️','标签',"wxQuickRun('get_tags')")}
|
||
${controlButton('群','群列表',"wxQuickRun('get_groups',{limit:500})")}
|
||
${controlButton('🧧','发红包',"wxCatalogRun('payment','send_red_packet')")}
|
||
${controlButton('🔐','账号状态',"wxQuickRun('check_account_status')")}
|
||
</div>
|
||
${msgSection}${aiSection}
|
||
</div></details>
|
||
` : `
|
||
<div class="app-action-grid" style="grid-template-columns:repeat(4,minmax(0,1fr))">
|
||
${controlButton(selectedApp.icon,'打开'+selectedApp.name,`doAction('open_${appTab}')`)}
|
||
${controlButton('↻','重连画面',"restartLiveScreen()")}
|
||
${controlButton('🏠','主页',"doAction('home')")}
|
||
${controlButton('↩️','返回',"doAction('back')")}
|
||
</div>
|
||
`}
|
||
</div>
|
||
</details>`;
|
||
|
||
if (!showFleet && current) {
|
||
window.setTimeout(() => {
|
||
if (state.current === 'devices' && state.deviceView === 'detail' && state.currentDevice === current.device_id) {
|
||
startRemoteScreen(current.device_id);
|
||
}
|
||
}, 60);
|
||
}
|
||
|
||
return `
|
||
${showFleet ? `<div class="card" style="margin-top:14px;padding:14px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px">
|
||
<div><span style="font-size:13px;font-weight:700">我的工作手机</span><span style="font-size:10px;color:var(--muted);margin-left:8px">点击手机后,下方所有操作切换到该设备</span></div>
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<input placeholder="搜索设备…" style="width:180px;padding:6px 10px;border-radius:10px;border:1px solid var(--border);font-size:11px;background:rgba(255,255,255,.92)" oninput="state.wxKeyword=this.value.toLowerCase();renderCurrentView(false)">
|
||
${filterBtns}
|
||
</div>
|
||
</div>
|
||
<div class="device-fleet-strip">${deviceCards}</div>
|
||
</div>` : ''}
|
||
<div class="device-control-layout">
|
||
<div class="device-tools-column">
|
||
<div class="card">${deviceInfoCard}</div>
|
||
${deviceManagementCard}
|
||
<details class="control-section"><summary><span>⚙️ 通用设置</span><span class="control-section-kicker">保活、检测周期、IMEI</span></summary><div class="control-section-body">${runtimeConfigCard || '<div style="color:var(--muted);font-size:11px">请选择设备</div>'}</div></details>
|
||
${compactAppSelector}
|
||
</div>
|
||
<div class="device-live-column">
|
||
<div class="card live-device-card" style="padding:12px">
|
||
<div class="live-device-head">
|
||
<div class="live-device-title">📱 实时设备控制</div>
|
||
<span class="live-device-status waiting" id="live-screen-status">正在连接</span>
|
||
</div>
|
||
<div class="phone-live-body">
|
||
<div class="shot" id="shot-box" style="height:min(66vh,680px);min-height:500px;touch-action:none;cursor:crosshair" onpointerdown="phonePointerStart(event)" onpointermove="phonePointerMove(event)" onpointerup="phonePointerEnd(event)" onpointercancel="phonePointerCancel(event)">
|
||
<canvas id="live-screen" width="272" height="600" aria-label="scrcpy 远程实时设备画面"></canvas>
|
||
<img id="live-screen-fallback" class="live-screen-fallback" alt="兼容设备画面" draggable="false" onload="fallbackScreenReady()" onerror="liveScreenFailed(this)">
|
||
</div>
|
||
<div class="live-device-hint">直接点击、滑动上方手机画面进行操作</div>
|
||
<div class="phone-live-actions live-device-tools">
|
||
<button class="phone-live-action" onclick="doAction('home')"><b>🏠</b>主页</button>
|
||
<button class="phone-live-action" onclick="doAction('back')"><b>↩️</b>返回</button>
|
||
<button class="phone-live-action" onclick="restartLiveScreen()"><b>↻</b>重连画面</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<details class="phone-more"><summary>📊 执行记录 <span style="float:right;color:var(--sub);font-weight:500">统计、日志、通道</span></summary><div class="phone-more-body">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:10px">
|
||
<div style="text-align:center;padding:6px;background:rgba(37,99,235,.04);border-radius:8px"><div style="font-size:16px;font-weight:800;color:var(--blue)" id="wx-total">${_wxStats.total}</div><div style="font-size:9px;color:var(--sub)">总操作</div></div>
|
||
<div style="text-align:center;padding:6px;background:rgba(22,163,74,.04);border-radius:8px"><div style="font-size:16px;font-weight:800;color:var(--green)" id="wx-rate">${_wxStats.total?Math.round(_wxStats.success/_wxStats.total*100)+'%':'-'}</div><div style="font-size:9px;color:var(--sub)">成功率</div></div>
|
||
</div>
|
||
<div style="font-size:11px;font-weight:700;margin-bottom:6px">操作日志</div><div id="device-action-logs" style="max-height:160px;overflow-y:auto">${logs || executionLogHtml || '<div style="color:var(--muted);font-size:11px;padding:4px">暂无记录</div>'}</div>
|
||
<div style="margin-top:10px;font-size:10px;color:var(--sub)">通道:Hook 50-200ms · u2 UI 2-15s · ADB 1-10s</div>
|
||
</div></details>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function loadAIConfig(){
|
||
if (state.aiConfigLoading) return;
|
||
state.aiConfigLoading = true;
|
||
try {
|
||
const res = await apiGet('/api/v3/ai/config');
|
||
state.aiConfig = res.data || {};
|
||
} catch (error) {
|
||
log(`AI接口配置读取失败:${error.message}`, 'error');
|
||
} finally {
|
||
state.aiConfigLoading = false;
|
||
if (state.current === 'devices') renderCurrentView(false);
|
||
}
|
||
}
|
||
|
||
async function saveAIConfig(){
|
||
const baseUrl = wxReadInput('ai-config-base');
|
||
const model = wxReadInput('ai-config-model');
|
||
const apiKey = wxReadInput('ai-config-key');
|
||
const tip = document.getElementById('ai-config-tip');
|
||
if (!baseUrl || !model) {
|
||
if (tip) tip.textContent = '请填写接口地址和模型名称';
|
||
return;
|
||
}
|
||
try {
|
||
const payload = {provider:'longxia', base_url:baseUrl, model};
|
||
if (apiKey) payload.api_key = apiKey;
|
||
const res = await apiPost('/api/v3/ai/config', payload);
|
||
state.aiConfig = res.data || {...state.aiConfig, base_url:baseUrl, model};
|
||
if (tip) tip.textContent = '已保存,Agent下次执行立即使用';
|
||
log('统一 AI 接口配置已保存', 'info');
|
||
} catch (error) {
|
||
if (tip) tip.textContent = `保存失败:${error.message}`;
|
||
log(`AI接口保存失败:${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
const WX_ACTION_ROUTES = {
|
||
send_message:{m:'POST',p:'/message/send'},get_messages:{m:'POST',p:'/message/list'},
|
||
get_unread_messages:{m:'POST',p:'/message/list'},get_recent_messages:{m:'POST',p:'/message/list'},
|
||
batch_send_message:{m:'POST',p:'/message/batch-send'},forward_message:{m:'POST',p:'/message/forward'},
|
||
recall_message:{m:'POST',p:'/message/recall'},send_card:{m:'POST',p:'/message/send-card'},
|
||
add_friend:{m:'POST',p:'/friend/add'},accept_friend:{m:'POST',p:'/friend/accept'},
|
||
set_remark:{m:'POST',p:'/friend/set-remark'},delete_friend:{m:'POST',p:'/friend/delete'},
|
||
get_contacts:{m:'GET',p:'/contacts'},search_contact:{m:'GET',p:'/contacts/search'},
|
||
get_friend_info:{m:'GET',p:'/contacts/search'},batch_add_friend:{m:'POST',p:'/friend/batch-add'},
|
||
create_group:{m:'POST',p:'/group/create'},invite_to_group:{m:'POST',p:'/group/invite'},
|
||
remove_from_group:{m:'POST',p:'/group/remove'},set_group_notice:{m:'POST',p:'/group/set-notice'},
|
||
set_group_name:{m:'POST',p:'/group/set-name'},send_group_message:{m:'POST',p:'/group/send-message'},
|
||
set_group_welcome:{m:'POST',p:'/group/set-welcome'},get_groups:{m:'GET',p:'/group/list'},
|
||
get_group_members:{m:'GET',p:'/group/members'},quit_group:{m:'POST',p:'/group/quit'},
|
||
create_tag:{m:'POST',p:'/tag/create'},delete_tag:{m:'POST',p:'/tag/delete'},
|
||
get_tags:{m:'GET',p:'/tag/list'},add_tag:{m:'POST',p:'/tag/add'},
|
||
remove_tag:{m:'POST',p:'/tag/remove'},get_users_by_tag:{m:'POST',p:'/tag/users'},
|
||
post_moments:{m:'POST',p:'/moments/post'},like_moments:{m:'POST',p:'/moments/like'},
|
||
comment_moments:{m:'POST',p:'/moments/comment'},get_moments:{m:'POST',p:'/moments/list'},
|
||
delete_moments:{m:'POST',p:'/moments/delete'},set_moments_cover:{m:'POST',p:'/moments/set-cover'},
|
||
set_moments_privacy:{m:'POST',p:'/moments/set-privacy'},forward_moments_link:{m:'POST',p:'/moments/share-link'},
|
||
get_profile:{m:'GET',p:'/profile/get'},set_nickname:{m:'POST',p:'/profile/set-nickname'},
|
||
set_signature:{m:'POST',p:'/profile/set-signature'},set_avatar:{m:'POST',p:'/profile/set-avatar'},
|
||
set_gender:{m:'POST',p:'/profile/set-gender'},set_region:{m:'POST',p:'/profile/set-region'},
|
||
check_account_status:{m:'GET',p:'/account/status'},safety_center:{m:'GET',p:'/account/safety-center'},
|
||
unblock_account:{m:'POST',p:'/account/unblock'},unblock_self:{m:'POST',p:'/account/unblock'},
|
||
unblock_appeal:{m:'POST',p:'/account/unblock'},unblock_with_sms:{m:'POST',p:'/account/unblock'},
|
||
change_password:{m:'POST',p:'/account/change-password'},check_restrictions:{m:'GET',p:'/account/status'},
|
||
appeal_restriction:{m:'POST',p:'/account/unblock'},
|
||
send_red_packet:{m:'POST',p:'/payment/red-packet'},transfer:{m:'POST',p:'/payment/transfer'},
|
||
show_payment_code:{m:'GET',p:'/payment/code'},view_wallet:{m:'GET',p:'/payment/wallet'},
|
||
view_transactions:{m:'GET',p:'/payment/transactions'},receive_red_packet:{m:'POST',p:'/hook/execute',hook:true},
|
||
receive_payment:{m:'POST',p:'/hook/execute',hook:true},
|
||
set_chat_top:{m:'POST',p:'/chat/set-top'},set_mute_chat:{m:'POST',p:'/chat/set-mute'},
|
||
clear_chat_history:{m:'POST',p:'/chat/clear-history'},
|
||
add_to_favorites:{m:'POST',p:'/favorites/add'},get_favorites:{m:'GET',p:'/favorites/list'},
|
||
open_mini_program:{m:'POST',p:'/miniprogram/open'},follow_official_account:{m:'POST',p:'/hook/execute',hook:true},
|
||
open_video_channel:{m:'GET',p:'/video-channel/list'},get_video_list:{m:'GET',p:'/video-channel/list'},
|
||
like_video:{m:'POST',p:'/hook/execute',hook:true},comment_video:{m:'POST',p:'/hook/execute',hook:true},
|
||
follow_video_creator:{m:'POST',p:'/hook/execute',hook:true},share_video:{m:'POST',p:'/hook/execute',hook:true},
|
||
scan_qr_code:{m:'POST',p:'/hook/execute',hook:true},scan_add_friend:{m:'POST',p:'/hook/execute',hook:true},
|
||
show_my_qr:{m:'GET',p:'/scan/my-qr'},extract_qr_from_image:{m:'POST',p:'/hook/execute',hook:true},
|
||
voice_call:{m:'POST',p:'/hook/execute',hook:true},video_call:{m:'POST',p:'/hook/execute',hook:true},
|
||
mass_send:{m:'POST',p:'/mass-send'},wechat_search:{m:'GET',p:'/search/wechat'},
|
||
top_stories:{m:'GET',p:'/discover/top-stories'},get_steps:{m:'GET',p:'/wechat-sport/steps'},
|
||
like_steps:{m:'POST',p:'/hook/execute',hook:true},send_location:{m:'POST',p:'/location/send'},
|
||
share_real_time_location:{m:'POST',p:'/hook/execute',hook:true},send_emoji:{m:'POST',p:'/emoji/send'},
|
||
get_sticker_list:{m:'GET',p:'/emoji/stickers'},send_voice_message:{m:'POST',p:'/message/voice'},
|
||
send_file_from_chat:{m:'POST',p:'/file/send'},toggle_do_not_disturb:{m:'POST',p:'/settings/do-not-disturb'},
|
||
clear_cache:{m:'POST',p:'/settings/clear-cache'},logout:{m:'POST',p:'/hook/execute',hook:true},
|
||
switch_account:{m:'POST',p:'/hook/execute',hook:true},
|
||
};
|
||
|
||
async function wxCall(action, params, actMeta){
|
||
const route = WX_ACTION_ROUTES[action] || {m:'POST',p:'/hook/execute',hook:true};
|
||
const act = actMeta || Object.values(WX_MODULES).flatMap(item => item.actions).find(item => item.a===action) || {n: action};
|
||
const finalParams = {platform:'wechat',device_id: state.currentDevice || '', ...(params || {})};
|
||
if(finalParams.to_ids && typeof finalParams.to_ids === 'string') finalParams.to_ids = finalParams.to_ids.split(',').map(s=>s.trim()).filter(Boolean);
|
||
if(finalParams.member_ids && typeof finalParams.member_ids === 'string') finalParams.member_ids = finalParams.member_ids.split(',').map(s=>s.trim()).filter(Boolean);
|
||
if(finalParams.user_ids && typeof finalParams.user_ids === 'string') finalParams.user_ids = finalParams.user_ids.split(',').map(s=>s.trim()).filter(Boolean);
|
||
if(finalParams.tags && typeof finalParams.tags === 'string') finalParams.tags = finalParams.tags.split(',').map(s=>s.trim()).filter(Boolean);
|
||
['limit','post_index','index','duration','days','interval'].forEach(k=>{if(finalParams[k]!==undefined && finalParams[k]!=='') finalParams[k]=Number(finalParams[k])});
|
||
finalParams.action = action;
|
||
|
||
const chainEls = ['wxc1','wxc2','wxc3','wxc4'];
|
||
chainEls.forEach(id=>{const e=document.getElementById(id);if(e)e.textContent='🟡 '+e.textContent.substring(2)});
|
||
|
||
const start = Date.now();
|
||
try{
|
||
let data;
|
||
if(route.hook){
|
||
const hookParams = {...finalParams};
|
||
delete hookParams.device_id;
|
||
delete hookParams.platform;
|
||
delete hookParams.action;
|
||
data = await apiPost(`/api/v3${route.p}`, {
|
||
device_id: finalParams.device_id,
|
||
platform: finalParams.platform,
|
||
action,
|
||
params: hookParams,
|
||
hook_only: false
|
||
});
|
||
} else if(route.m==='GET'){
|
||
const qs=Object.entries(finalParams).filter(([k,v])=>v!==undefined && v!==null && v!=='').map(([k,v])=>`${k}=${encodeURIComponent(Array.isArray(v) ? v.join(',') : v)}`).join('&');
|
||
data = await apiGet(`/api/v3${route.p}?${qs}`);
|
||
} else {
|
||
data = await apiPost(`/api/v3${route.p}`, finalParams);
|
||
}
|
||
const ms = Date.now() - start;
|
||
const ch = data.channel_used || data.data?.channel || 'sdk_control';
|
||
const nestedCode = Number(data.data?.code);
|
||
const ok = (data.code===200||data.code===undefined) && data.success!==false && data.data?.success!==false && !(nestedCode>=400);
|
||
_wxStats.total++; if(ok)_wxStats.success++; _wxStats.totalTime+=ms;
|
||
_wxLogs.push({ts:new Date().toLocaleTimeString(),action:act.n,ok,ch:String(ch),ms});
|
||
|
||
chainEls.forEach(id=>{const e=document.getElementById(id);if(e)e.textContent=(ok?'🟢 ':'🔴 ')+e.textContent.substring(2)});
|
||
const res=document.getElementById('wx-result');if(res)res.innerHTML=ok?wxResultHtml(data):`<div class="wx-result-error">${esc(wxResponseMessage(data))}</div>`;
|
||
const resultChannel=document.getElementById('wx-result-channel');if(resultChannel)resultChannel.textContent=`${ch} · ${ms}ms`;
|
||
const total=document.getElementById('wx-total');if(total)total.textContent=_wxStats.total;
|
||
const rate=document.getElementById('wx-rate');if(rate)rate.textContent=Math.round(_wxStats.success/_wxStats.total*100)+'%';
|
||
const avg=document.getElementById('wx-avg');if(avg)avg.textContent=Math.round(_wxStats.totalTime/_wxStats.total)+'ms';
|
||
const chEl=document.getElementById('wx-ch');if(chEl)chEl.textContent=ch;
|
||
const logBox=document.getElementById('device-action-logs');
|
||
if(logBox){
|
||
const entry=`<div style="padding:3px 0;font-size:11px;border-bottom:1px solid var(--border)"><span style="color:var(--blue)">${new Date().toLocaleTimeString()}</span> <span style="font-weight:600">${esc(act.n)}</span> <span style="color:${ok?'var(--green)':'var(--red)'};">${ok?'✓':'✗'}</span> <span style="color:var(--sub)">${ch} ${ms}ms</span></div>`;
|
||
logBox.innerHTML=entry+logBox.innerHTML;
|
||
}
|
||
log(`微信 ${act.n}: ${ok?'成功':'失败'} (${ch}, ${ms}ms)${ok?'':' — '+wxResponseMessage(data)}`, ok?'info':'error');
|
||
}catch(e){
|
||
const ms=Date.now()-start;
|
||
_wxStats.total++; _wxStats.totalTime+=ms;
|
||
_wxLogs.push({ts:new Date().toLocaleTimeString(),action:act.n,ok:false,ch:'error',ms});
|
||
chainEls.forEach(id=>{const e2=document.getElementById(id);if(e2)e2.textContent='🔴 '+e2.textContent.substring(2)});
|
||
const res=document.getElementById('wx-result');if(res)res.innerHTML=`<div class="wx-result-error">${esc(e.message || '请求失败')}</div>`;
|
||
const resultChannel=document.getElementById('wx-result-channel');if(resultChannel)resultChannel.textContent='请求失败';
|
||
log(`微信 ${act.n}: 失败 — ${e.message}`,'error');
|
||
}
|
||
}
|
||
|
||
async function wxExec(action){
|
||
const m = WX_MODULES[_wxModule];
|
||
const act = m?.actions.find(a=>a.a===action);
|
||
if(!act) return;
|
||
const params = {};
|
||
act.f.forEach(f=>{
|
||
const el = document.getElementById(`wx_${action}_${f.k}`);
|
||
if(el) params[f.k] = el.value || f.v || '';
|
||
});
|
||
return wxCall(action, params, act);
|
||
}
|
||
|
||
async function wxQuickRun(action, fieldMap){
|
||
const params = {};
|
||
Object.entries(fieldMap || {}).forEach(([key, source]) => {
|
||
if (typeof source === 'function') {
|
||
params[key] = source();
|
||
return;
|
||
}
|
||
if (typeof source === 'string' && document.getElementById(source)) {
|
||
params[key] = wxReadInput(source);
|
||
return;
|
||
}
|
||
params[key] = source;
|
||
});
|
||
return wxCall(action, params);
|
||
}
|
||
|
||
function wxCatalogRun(moduleId, action){
|
||
const module = WX_MODULES[moduleId];
|
||
const act = module?.actions.find(item=>item.a===action);
|
||
if(!act) return;
|
||
const params = {};
|
||
for(const field of (act.f || [])){
|
||
const value = prompt(`${act.n} · ${field.l}`, field.v || '');
|
||
if(value === null) return;
|
||
params[field.k] = value;
|
||
}
|
||
return wxCall(action, params, act);
|
||
}
|
||
|
||
function renderWxActionCatalog(){
|
||
return Object.entries(WX_MODULES).map(([moduleId,module])=>`
|
||
<details class="app-fold">
|
||
<summary>📂 ${module.title}<span class="pill">${module.actions.length} 项</span></summary>
|
||
<div class="app-fold-body">
|
||
<div class="app-action-grid">
|
||
${module.actions.map(act=>`<button class="app-action-btn" onclick="wxCatalogRun('${moduleId}','${act.a}')"><span class="icon">⚙️</span><span>${act.n}</span></button>`).join('')}
|
||
</div>
|
||
</div>
|
||
</details>`).join('');
|
||
}
|
||
|
||
function wxResponseMessage(data){
|
||
const payload = data?.data;
|
||
return data?.message || data?.detail || payload?.message || payload?.error || payload?.detail || '请求失败';
|
||
}
|
||
|
||
function wxUnavailableGuide(data){
|
||
const detail = wxResponseMessage(data);
|
||
const is503 = Number(data?.code)===503 || Number(data?.status)===503 || /503/.test(String(detail));
|
||
if(!is503) return `// 错误\n${detail}`;
|
||
const wsOnline = state.devices?.find(d=>d.device_id===state.currentDevice)?.status === 'online';
|
||
return `// 微信通道暂不可用\n${detail}\n\n处理建议:${wsOnline?'检查手机端 Frida/Hook 状态':'启动手机端 Agent,保持 WS 连接后再执行微信数据操作'}\n当前设备:${state.currentDevice || '-'}\n说明:ADB 连接只负责设备级操作,好友、资料、朋友圈等数据需要 WS + 微信通道。`;
|
||
}
|
||
|
||
/* ═══ 2. Devices Panel (设备控制 / 接口文档 / 系统说明) ═══ */
|
||
|
||
function renderDevicePanel(){
|
||
const devices = state.devices || [];
|
||
const wsCount = devices.filter(d=>d.status==='online').length;
|
||
const adbCount = devices.filter(deviceHasAdb).length;
|
||
const onlineCount = devices.filter(d=>
|
||
d.status==='online' ||
|
||
d.status==='adb' ||
|
||
d.adb_status==='device' ||
|
||
d.controllable===true
|
||
).length;
|
||
const unauth = devices.filter(d=>d.status==='unauthorized'||d.adb_status==='unauthorized');
|
||
if (state.deviceView === 'detail' && state.currentDevice) {
|
||
const current = devices.find(d => d.device_id === state.currentDevice);
|
||
const title = current?.profile_nickname || current?.nickname || current?.model || state.currentDevice;
|
||
return `
|
||
<div class="panel glass device-detail-header">
|
||
<div class="panel-head" style="margin-bottom:0">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<button class="btn soft" onclick="state.deviceView='list';renderCurrentView()">← 返回手机设备</button>
|
||
<div><h2>📱 ${esc(title)}</h2><p class="sub">设备控制详情 · 所有操作仅作用于当前手机</p></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<button class="btn soft" onclick="loadDeviceDetail('${esc(state.currentDevice)}')">🔄 刷新设备</button>
|
||
${current?.status === 'offline' ? `<button class="btn danger" onclick="deleteOfflineDevice('${esc(state.currentDevice)}')">删除失效设备</button>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>${renderUnifiedControl(false)}`;
|
||
}
|
||
const cards = devices.length ? devices.map(device => {
|
||
const tone = wxStatusTone(device);
|
||
const name = device.profile_nickname || device.nickname || device.model || device.device_id;
|
||
const wxRunning = device.wechat_running || device.current_app === 'com.tencent.mm';
|
||
const online = device.status === 'online' || device.status === 'adb' || device.adb_status === 'device' || device.controllable === true;
|
||
return `<button class="phone-device-card" onclick="openDeviceDetail('${esc(device.device_id)}')">
|
||
<span class="phone-online-dot" style="background:${online?'var(--green)':'var(--muted)'}"></span>
|
||
<span class="phone-shell"><span class="phone-speaker"></span><span class="phone-screen"><span class="phone-screen-icon">📱</span><span>${online?'已连接':'离线'}</span></span></span>
|
||
<strong>${esc(name)}</strong>
|
||
<small>${esc(device.brand||'Android')} · ${esc(device.device_id)}</small>
|
||
<span class="phone-tags"><i style="color:${tone.color}">● ${tone.label}</i><i>${wxRunning?'微信运行':'微信待启动'}</i></span>
|
||
<span class="phone-enter">进入控制台 →</span>
|
||
</button>`;
|
||
}).join('') : `<div class="card" style="grid-column:1/-1;text-align:center;padding:54px 20px">${renderConnectGuide()}</div>`;
|
||
const header = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>📱 手机设备</h2>
|
||
<p class="sub">${devices.length} 台手机 · ${onlineCount} 在线 · 点击手机进入二级控制页面</p>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span class="mode-badge ${wsCount>0?'available':'unavailable'}" title="WebSocket"><span class="badge-dot"></span>WS ${wsCount}</span>
|
||
<span class="mode-badge ${adbCount>0?'available':'unavailable'}" title="ADB"><span class="badge-dot"></span>ADB ${adbCount}</span>
|
||
${unauth.length?`<span class="mode-badge unavailable"><span class="badge-dot"></span>⚠ ${unauth.length} 待授权</span>`:''}
|
||
<button class="btn soft" style="font-size:11px" onclick="refreshDevices()">🔄 刷新</button>
|
||
<button class="btn primary" style="font-size:11px" onclick="openBindQRCode()">📷 扫码绑定</button>
|
||
<button class="btn soft" style="font-size:11px" onclick="adbRescan()">📡 扫描 ADB</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
return header + `<div class="phone-device-grid">${cards}</div>`;
|
||
}
|
||
|
||
function openDeviceDetail(deviceId){
|
||
state.current = 'devices';
|
||
state.deviceView = 'detail';
|
||
state.currentDevice = deviceId;
|
||
renderSidebar();
|
||
renderCurrentView();
|
||
window.setTimeout(() => {
|
||
if (state.current === 'devices' && state.deviceView === 'detail' && state.currentDevice === deviceId) {
|
||
loadDeviceDetail(deviceId);
|
||
}
|
||
}, 3000);
|
||
log(`进入手机控制:${deviceId}`, 'info');
|
||
}
|
||
|
||
function loadIntegrationManifest(){
|
||
if (state.integrationManifest || state.integrationManifestLoading) return;
|
||
state.integrationManifestLoading = true;
|
||
state.integrationManifestError = '';
|
||
apiGet('/api/v3/integration/manifest')
|
||
.then(res => {
|
||
state.integrationManifest = res.data || res;
|
||
state.integrationManifestLoading = false;
|
||
renderCurrentView(false);
|
||
})
|
||
.catch(err => {
|
||
state.integrationManifestLoading = false;
|
||
state.integrationManifestError = err.message || String(err);
|
||
renderCurrentView(false);
|
||
});
|
||
}
|
||
|
||
function renderApiDocsDynamicTab(){
|
||
loadIntegrationManifest();
|
||
const manifest = state.integrationManifest;
|
||
if (!manifest) {
|
||
return `
|
||
<div class="panel glass" style="margin-top:14px">
|
||
<div class="panel-head">
|
||
<div><h2>📡 全部 API 接口文档</h2><p class="sub">正在从 /api/v3/integration/manifest 实时加载接口清单...</p></div>
|
||
<div class="btns"><a class="btn primary" href="/docs" target="_blank" style="text-decoration:none">📄 Swagger</a><a class="btn soft" href="/redoc" target="_blank" style="text-decoration:none">ReDoc</a></div>
|
||
</div>
|
||
${state.integrationManifestError ? `<div class="card" style="border-color:rgba(220,38,38,.25);color:var(--red)">加载失败:${esc(state.integrationManifestError)}</div>` : `<div class="card">接口中心加载中,稍等 1 秒自动刷新。</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
const modules = manifest.modules || [];
|
||
const totalEndpoints = manifest.total_endpoints || modules.reduce((sum, mod) => sum + ((mod.endpoints || []).length), 0);
|
||
const consumers = manifest.consumers || {};
|
||
const expandedCat = state._apiDocExpanded || (modules[0] && (modules[0].module || modules[0].id)) || '';
|
||
const copyPath = p => String(p || '').replace(/'/g, "\\'");
|
||
const methodTone = m => m === 'GET' ? '37,99,235' : m === 'POST' ? '22,163,74' : m === 'DELETE' ? '220,38,38' : '245,158,11';
|
||
const mkEndpoint = ep => {
|
||
const m = ep.method || 'GET';
|
||
const p = ep.path || '';
|
||
const summary = ep.summary || ep.name || '接口';
|
||
return `<div style="display:grid;grid-template-columns:58px minmax(0,1fr) minmax(120px,240px);align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.04)">
|
||
<code style="background:rgba(${methodTone(m)},.08);padding:2px 7px;border-radius:5px;font-size:10px;text-align:center;font-weight:700">${esc(m)}</code>
|
||
<span style="font-family:monospace;font-size:11px;color:var(--text);cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" onclick="navigator.clipboard.writeText('${copyPath(p)}');this.style.color='var(--green)';setTimeout(()=>this.style.color='var(--text)',1000)" title="点击复制路径">${esc(p)}</span>
|
||
<span style="color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(summary)}</span>
|
||
</div>`;
|
||
};
|
||
|
||
return `
|
||
<div class="panel glass" style="margin-top:14px">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>📡 全部 API 接口文档</h2>
|
||
<p class="sub">${totalEndpoints} 个实时端点 · ${modules.length} 个模块 · 来源 /api/v3/integration/manifest · 点击路径可复制</p>
|
||
</div>
|
||
<div class="btns">
|
||
<a class="btn primary" href="/docs" target="_blank" style="text-decoration:none">📄 Swagger</a>
|
||
<a class="btn soft" href="/redoc" target="_blank" style="text-decoration:none">ReDoc</a>
|
||
<button class="btn soft" onclick="state.integrationManifest=null;state._apiDocExpanded='';renderCurrentView(false)">🔄 刷新清单</button>
|
||
</div>
|
||
</div>
|
||
<div class="grid col4" style="margin-bottom:12px">
|
||
<div class="card"><h3>总端点</h3><p style="font-size:24px;font-weight:800;color:var(--blue)">${totalEndpoints}</p></div>
|
||
<div class="card"><h3>模块</h3><p style="font-size:24px;font-weight:800;color:var(--teal)">${modules.length}</p></div>
|
||
<div class="card"><h3>存客宝</h3><p style="font-size:18px;font-weight:800;color:var(--green)">${esc(consumers.cunkebao || '可筛选')}</p></div>
|
||
<div class="card"><h3>对接原则</h3><p>统一走 /api/v3/integration/* 发现能力,业务调用再进具体接口。</p></div>
|
||
</div>
|
||
<div style="display:grid;gap:10px">
|
||
${modules.map(mod => {
|
||
const id = mod.module || mod.id || mod.label || 'module';
|
||
const isOpen = expandedCat === id;
|
||
const endpoints = mod.endpoints || [];
|
||
const consumerText = (mod.consumers || []).join(' / ');
|
||
return `<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;transition:background .15s" onclick="state._apiDocExpanded=state._apiDocExpanded==='${esc(id)}'?'':'${esc(id)}';renderCurrentView(false)" onmouseover="this.style.background='rgba(37,99,235,.03)'" onmouseout="this.style.background='transparent'">
|
||
<span style="font-size:18px">📦</span>
|
||
<div style="flex:1"><span style="font-size:13px;font-weight:700">${esc(mod.label || id)}</span><span style="font-size:11px;color:var(--muted);margin-left:8px">${endpoints.length} 个端点 · ${esc(consumerText || 'common')}</span></div>
|
||
<code style="font-size:10px;color:var(--muted)">${esc(id)}</code>
|
||
<span style="font-size:14px;color:var(--muted);transition:transform .2s;transform:rotate(${isOpen?'180':'0'}deg)">▼</span>
|
||
</div>
|
||
${isOpen ? '<div style="padding:4px 16px 14px;border-top:1px solid var(--border);font-size:11px;line-height:2">'+endpoints.map(mkEndpoint).join('')+'</div>' : ''}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderApiDocsTab(){
|
||
const API_CATEGORIES = [
|
||
{id:'device',title:'设备管理',icon:'📱',color:'var(--blue)',endpoints:[
|
||
['GET','/api/v3/devices','获取所有设备列表'],['GET','/api/v3/devices/{id}','获取设备详情'],
|
||
['POST','/api/v3/devices/{id}/screenshot','截图'],['POST','/api/v3/devices/{id}/click','点击坐标'],
|
||
['POST','/api/v3/devices/{id}/click-text','点击文字'],['POST','/api/v3/devices/{id}/input','输入文字'],
|
||
['POST','/api/v3/devices/{id}/swipe','滑动'],['GET','/api/v3/devices/{id}/ui-tree','获取UI树'],
|
||
['POST','/api/v3/devices/{id}/execute','执行脚本'],['GET','/api/v3/devices/{id}/heartbeat','心跳状态'],
|
||
['POST','/api/v3/devices/{id}/heartbeat/config','心跳配置'],['GET','/api/v3/devices/{id}/health','健康评分'],
|
||
['GET','/api/v3/devices/{id}/health/events','健康事件'],['GET','/api/v3/heartbeat/dashboard','心跳仪表盘'],
|
||
['GET','/api/v3/heartbeat/events','全局健康事件'],
|
||
]},
|
||
{id:'adb',title:'ADB 操作',icon:'🔌',color:'var(--teal)',endpoints:[
|
||
['GET','/api/v3/adb/devices','ADB设备列表'],['POST','/api/v3/adb/scan','扫描设备'],
|
||
['GET','/api/v3/adb/devices/{serial}','设备详情'],['POST','/api/v3/adb/devices/{serial}/screenshot','截图'],
|
||
['POST','/api/v3/adb/devices/{serial}/click','点击'],['POST','/api/v3/adb/devices/{serial}/input','输入'],
|
||
['POST','/api/v3/adb/devices/{serial}/swipe','滑动'],['POST','/api/v3/adb/devices/{serial}/key','按键'],
|
||
['POST','/api/v3/adb/devices/{serial}/app/start','启动APP'],['POST','/api/v3/adb/devices/{serial}/app/stop','停止APP'],
|
||
['GET','/api/v3/adb/devices/{serial}/app/current','当前APP'],['GET','/api/v3/adb/devices/{serial}/app/list','已安装APP'],
|
||
['GET','/api/v3/adb/devices/{serial}/ui-tree','UI树'],['POST','/api/v3/adb/devices/{serial}/click-text','点击文字'],
|
||
]},
|
||
{id:'wx-msg',title:'微信 · 消息',icon:'💬',color:'var(--green)',endpoints:[
|
||
['POST','/api/v3/message/send','发送消息'],['POST','/api/v3/message/list','消息列表'],
|
||
['POST','/api/v3/message/batch-send','批量发送'],['POST','/api/v3/message/forward','转发'],
|
||
['POST','/api/v3/message/recall','撤回'],['POST','/api/v3/message/send-card','发名片'],
|
||
['POST','/api/v3/comment/reply','回复评论'],
|
||
]},
|
||
{id:'wx-friend',title:'微信 · 好友',icon:'👥',color:'var(--blue)',endpoints:[
|
||
['POST','/api/v3/friend/add','添加好友'],['POST','/api/v3/friend/accept','通过请求'],
|
||
['POST','/api/v3/friend/set-remark','设置备注'],['POST','/api/v3/friend/delete','删除好友'],
|
||
['POST','/api/v3/friend/batch-add','批量加友'],['GET','/api/v3/contacts','联系人列表'],
|
||
['GET','/api/v3/contacts/search','搜索联系人'],['GET','/api/v3/friend/info','好友资料'],
|
||
]},
|
||
{id:'wx-group',title:'微信 · 群聊',icon:'👪',color:'var(--purple)',endpoints:[
|
||
['POST','/api/v3/group/create','创建群'],['POST','/api/v3/group/invite','邀请入群'],
|
||
['POST','/api/v3/group/remove','移出群'],['POST','/api/v3/group/set-notice','群公告'],
|
||
['POST','/api/v3/group/set-name','群名'],['POST','/api/v3/group/send-message','群消息'],
|
||
['POST','/api/v3/group/set-welcome','欢迎语'],['GET','/api/v3/group/list','群列表'],
|
||
['GET','/api/v3/group/members','群成员'],['POST','/api/v3/group/quit','退群'],
|
||
]},
|
||
{id:'wx-tag',title:'微信 · 标签',icon:'🏷️',color:'var(--orange)',endpoints:[
|
||
['POST','/api/v3/tag/create','创建标签'],['POST','/api/v3/tag/delete','删除标签'],
|
||
['GET','/api/v3/tag/list','标签列表'],['POST','/api/v3/tag/add','添加标签'],
|
||
['POST','/api/v3/tag/remove','移除标签'],['POST','/api/v3/tag/users','按标签查好友'],
|
||
]},
|
||
{id:'wx-moments',title:'微信 · 朋友圈',icon:'📸',color:'var(--teal)',endpoints:[
|
||
['POST','/api/v3/moments/post','发朋友圈'],['POST','/api/v3/moments/like','点赞'],
|
||
['POST','/api/v3/moments/comment','评论'],['POST','/api/v3/moments/list','朋友圈列表'],
|
||
['POST','/api/v3/moments/delete','删除'],
|
||
]},
|
||
{id:'wx-profile',title:'微信 · 个人设置',icon:'👤',color:'var(--blue)',endpoints:[
|
||
['GET','/api/v3/profile/get','获取资料'],['POST','/api/v3/profile/set-nickname','改昵称'],
|
||
['POST','/api/v3/profile/set-signature','改签名'],['POST','/api/v3/profile/set-avatar','改头像'],
|
||
['POST','/api/v3/profile/set-gender','设性别'],['POST','/api/v3/profile/set-region','设地区'],
|
||
]},
|
||
{id:'wx-account',title:'微信 · 账号安全',icon:'🔒',color:'var(--red)',endpoints:[
|
||
['GET','/api/v3/account/status','账号状态'],['POST','/api/v3/account/unblock','解封'],
|
||
['GET','/api/v3/account/safety-center','安全中心'],['POST','/api/v3/account/change-password','改密码'],
|
||
['GET','/api/v3/account/restrictions','功能限制'],['POST','/api/v3/account/appeal-restriction','申诉'],
|
||
]},
|
||
{id:'ai',title:'AI 引擎',icon:'🧠',color:'var(--purple)',endpoints:[
|
||
['POST','/api/v3/agent/execute','执行任务'],['GET','/api/v3/agent/status/{id}','Agent状态'],
|
||
['POST','/api/v3/agent/stop/{id}','停止任务'],['GET','/api/v3/ai/status','AI状态'],
|
||
['POST','/api/v3/ai/chat','AI对话控制'],['POST','/api/v3/v1/chat/completions','OpenAI兼容'],
|
||
['GET','/api/v3/devices/{id}/ai/status','AI Brain状态'],['POST','/api/v3/devices/{id}/ai/task','推送AI任务'],
|
||
['POST','/api/v3/devices/{id}/ai/standing-order','常驻指令'],['POST','/api/v3/devices/{id}/ai/execute','AI执行'],
|
||
['POST','/api/v3/devices/{id}/ai/queue-task','任务队列'],
|
||
]},
|
||
{id:'hook',title:'Hook 模块',icon:'🪝',color:'var(--orange)',endpoints:[
|
||
['GET','/api/v3/hook/actions','Hook操作清单'],['GET','/api/v3/hook/probe/{id}','探测Hook状态'],
|
||
['GET','/api/v3/hook/data/{id}','拉取Hook数据'],['POST','/api/v3/hook/execute','执行Hook操作'],
|
||
['GET','/api/v3/modules','模块列表'],['POST','/api/v3/modules','创建模块'],
|
||
['GET','/api/v3/devices/{id}/modules','设备模块'],['POST','/api/v3/devices/{id}/modules/reload','重载模块'],
|
||
['GET','/api/v3/hook/events','事件列表'],['POST','/api/v3/hook/events','写入事件'],
|
||
]},
|
||
{id:'gateway',title:'对接网关',icon:'🔗',color:'var(--green)',endpoints:[
|
||
['POST','/api/v3/gateway/v1/chat/completions','OpenAI Chat'],['GET','/api/v3/gateway/v1/models','模型列表'],
|
||
['GET','/api/v3/gateway/mcp/tools','MCP工具列表'],['POST','/api/v3/gateway/mcp/call','调用MCP工具'],
|
||
['GET','/api/v3/gateway/info','网关信息'],['GET','/api/v3/gateway/fleet/status','多设备状态'],
|
||
['POST','/api/v3/gateway/fleet/broadcast','广播命令'],['GET','/api/v3/gateway/journey/{id}','AI旅程'],
|
||
]},
|
||
{id:'conn',title:'连接协议',icon:'🔌',color:'var(--teal)',endpoints:[
|
||
['GET','/api/v3/connection/protocol','协议结构'],['GET','/api/v3/connection/status','连接状态'],
|
||
['GET','/api/v3/connection/modes/{id}','设备控制模式'],['GET','/api/v3/connection/modes','所有设备模式'],
|
||
['POST','/api/v3/connection/simulate/register','模拟注册'],['POST','/api/v3/connection/simulate/heartbeat','模拟心跳'],
|
||
]},
|
||
{id:'misc',title:'其他服务',icon:'⚡',color:'var(--sub)',endpoints:[
|
||
['POST','/api/v3/qrcode/generate','生成绑定二维码'],['GET','/api/v3/qrcode/image/{id}','二维码图片'],
|
||
['POST','/api/v3/voice/command','语音命令'],['GET','/api/v3/experience/statistics','经验库统计'],
|
||
['GET','/api/v3/experience/operations','操作记录'],['POST','/api/v3/capture/start','开始抓包'],
|
||
['POST','/api/v3/registry/nodes/register','节点注册'],['GET','/api/v3/registry/nodes','节点列表'],
|
||
]},
|
||
];
|
||
const totalEndpoints = API_CATEGORIES.reduce((s,c)=>s+c.endpoints.length,0);
|
||
const expandedCat = state._apiDocExpanded || '';
|
||
const mkEndpoint = (m,p,d) => `<div style="display:flex;align-items:center;gap:6px;padding:4px 0"><code style="background:rgba(${m==='GET'?'37,99,235':m==='POST'?'22,163,74':m==='DELETE'?'220,38,38':'245,158,11'},.08);padding:1px 6px;border-radius:4px;font-size:10px;min-width:40px;text-align:center;font-weight:600">${m}</code><span style="font-family:monospace;font-size:10.5px;color:var(--text);cursor:pointer" onclick="navigator.clipboard.writeText('${p}');this.style.color='var(--green)';setTimeout(()=>this.style.color='var(--text)',1000)" title="点击复制路径">${p}</span><span style="color:var(--muted);font-size:11px;margin-left:auto;white-space:nowrap">${d}</span></div>`;
|
||
return `
|
||
<div class="panel glass" style="margin-top:14px">
|
||
<div class="panel-head">
|
||
<div><h2>\ud83d\udce1 \u5168\u90e8 API \u63a5\u53e3\u6587\u6863</h2><p class="sub">${totalEndpoints} \u4e2a\u7aef\u70b9 \u00b7 ${API_CATEGORIES.length} \u4e2a\u5206\u7c7b \u00b7 \u70b9\u51fb\u8def\u5f84\u53ef\u590d\u5236</p></div>
|
||
<div class="btns"><a class="btn primary" href="/docs" target="_blank" style="text-decoration:none">\ud83d\udcc4 Swagger</a><a class="btn soft" href="/redoc" target="_blank" style="text-decoration:none">ReDoc</a></div>
|
||
</div>
|
||
<div style="display:grid;gap:10px">
|
||
${API_CATEGORIES.map(cat => {
|
||
const isOpen = expandedCat === cat.id;
|
||
return `<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;transition:background .15s" onclick="state._apiDocExpanded=state._apiDocExpanded==='${cat.id}'?'':'${cat.id}';renderCurrentView(false)" onmouseover="this.style.background='rgba(37,99,235,.03)'" onmouseout="this.style.background='transparent'">
|
||
<span style="font-size:18px">${cat.icon}</span>
|
||
<div style="flex:1"><span style="font-size:13px;font-weight:700">${cat.title}</span><span style="font-size:11px;color:var(--muted);margin-left:8px">${cat.endpoints.length} \u4e2a\u7aef\u70b9</span></div>
|
||
<span style="font-size:14px;color:var(--muted);transition:transform .2s;transform:rotate(${isOpen?'180':'0'}deg)">\u25bc</span>
|
||
</div>
|
||
${isOpen ? '<div style="padding:4px 16px 14px;border-top:1px solid var(--border);font-size:11px;line-height:2">'+cat.endpoints.map(([m,p,d])=>mkEndpoint(m,p,d)).join('')+'</div>' : ''}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderSystemFlowTab(){
|
||
const devices = state.devices || [];
|
||
const onlineCount = devices.filter(d=>['online','adb'].includes(d.status||d.adb_status)).length;
|
||
const flowStep = (step,icon,title,desc,color) => `<div style="flex:1;text-align:center;padding:16px 12px;border-radius:14px;border:1px solid var(--border);background:var(--panel-strong);position:relative"><div style="font-size:9px;font-weight:800;color:${color};letter-spacing:.08em;text-transform:uppercase">STEP ${step}</div><div style="font-size:24px;margin:6px 0">${icon}</div><div style="font-size:13px;font-weight:700">${title}</div><div style="font-size:11px;color:var(--sub);margin-top:4px;line-height:1.5">${desc}</div></div>`;
|
||
return `
|
||
<div class="panel glass" style="margin-top:14px">
|
||
<div class="panel-head"><div><h2>\ud83d\udcca \u7cfb\u7edf\u67b6\u6784\u603b\u89c8</h2><p class="sub">${devices.length} \u53f0\u8bbe\u5907 \u00b7 ${onlineCount} \u5728\u7ebf \u00b7 \u591a\u901a\u9053\u667a\u80fd\u63a7\u5236</p></div></div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:16px">
|
||
${flowStep('1','\ud83d\udcbb','\u5ba2\u6237\u7aef','\u5b58\u5ba2\u5b9d/API/MCP','var(--blue)')}
|
||
<div style="font-size:22px;color:var(--muted);flex-shrink:0">\u2192</div>
|
||
${flowStep('2','\u2699\ufe0f','\u670d\u52a1\u7aef','FastAPI + unified.py','var(--teal)')}
|
||
<div style="font-size:22px;color:var(--muted);flex-shrink:0">\u2192</div>
|
||
${flowStep('3','\ud83d\udd00','\u901a\u9053\u8def\u7531','Hook > u2 > ADB > AI','var(--purple)')}
|
||
<div style="font-size:22px;color:var(--muted);flex-shrink:0">\u2192</div>
|
||
${flowStep('4','\ud83d\udcf1','\u8bbe\u5907\u6267\u884c','Agent / ADB / Frida','var(--green)')}
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>\ud83d\udd00 \u56db\u901a\u9053\u63a7\u5236\u94fe\u8def</h2><p class="sub">\u667a\u80fd\u901a\u9053\u9009\u62e9\uff1a\u4f18\u5148 Hook \u2192 \u964d\u7ea7 u2 \u2192 ADB \u2192 AI</p></div></div>
|
||
<div class="grid col4">
|
||
<div class="card" style="border-top:3px solid var(--purple)">
|
||
<h3 style="color:var(--purple)">\ud83e\ude9d \u901a\u9053 1\uff1aFrida Hook</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">\u76f4\u63a5\u8c03\u7528 APP \u5185\u90e8\u51fd\u6570<br>\u53d1\u6d88\u606f/\u8bfb\u8054\u7cfb\u4eba/\u7fa4\u7ba1\u7406<br><b>\u5ef6\u8fdf\uff1a50-200ms</b><br>\u9690\u853d\u6027\u9ad8\uff0c\u4e0d\u89e6\u53d1 UI</div>
|
||
<div style="margin-top:8px"><span class="mode-badge best"><span class="badge-dot"></span>\u4f18\u5148</span></div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--blue)">
|
||
<h3 style="color:var(--blue)">\ud83d\uddb1\ufe0f \u901a\u9053 2\uff1auiautomator2</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">UI \u81ea\u52a8\u5316\u64cd\u4f5c<br>\u70b9\u51fb/\u6ed1\u52a8/\u8f93\u5165/\u622a\u56fe<br><b>\u5ef6\u8fdf\uff1a2-15s</b><br>Frida \u4e0d\u53ef\u7528\u65f6\u81ea\u52a8\u964d\u7ea7</div>
|
||
<div style="margin-top:8px"><span class="mode-badge available"><span class="badge-dot"></span>\u5146\u5e95</span></div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--orange)">
|
||
<h3 style="color:var(--orange)">\ud83d\udcf1 \u901a\u9053 3\uff1aADB \u76f4\u63a7</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">\u8bbe\u5907\u7ea7\u522b\u63a7\u5236<br>\u5b89\u88c5/\u542f\u52a8/\u622a\u56fe/\u6309\u952e<br><b>\u5ef6\u8fdf\uff1a1-10s</b><br>USB \u6216 WiFi \u8fde\u63a5</div>
|
||
<div style="margin-top:8px"><span class="mode-badge available"><span class="badge-dot"></span>\u57fa\u7840</span></div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--green)">
|
||
<h3 style="color:var(--green)">\ud83e\udd16 \u901a\u9053 4\uff1aAI Agent</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">\u81ea\u7136\u8bed\u8a00 \u2192 \u6b65\u9aa4\u62c6\u89e3 \u2192 \u6267\u884c<br>\u4f9d\u8d56\u901a\u9053 1 \u6216 2<br><b>\u5ef6\u8fdf\uff1a3-30s</b><br>\u667a\u80fd\u51b3\u7b56 + \u5f02\u5e38\u6062\u590d</div>
|
||
<div style="margin-top:8px"><span class="mode-badge available"><span class="badge-dot"></span>\u589e\u5f3a</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>\ud83c\udf10 \u4e09\u79cd\u5bf9\u63a5\u65b9\u5f0f</h2><p class="sub">\u5916\u90e8\u7a0b\u5e8f\u901a\u8fc7\u4efb\u4e00\u65b9\u5f0f\u63a5\u5165 SDK\uff0c\u5b9e\u73b0\u591a\u53f0\u624b\u673a\u7edf\u4e00\u7ba1\u63a7</p></div></div>
|
||
<div class="grid col3">
|
||
<div class="card" style="border-top:3px solid var(--blue)">
|
||
<h3 style="color:var(--blue)">\ud83c\udf10 REST API</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">150+ \u7aef\u70b9\uff0c\u8986\u76d6\u5fae\u4fe1\u5168\u529f\u80fd<br>\u8bbe\u5907\u63a7\u5236\u3001AI \u4efb\u52a1\u3001Hook \u7ba1\u7406</div>
|
||
<div class="path" style="margin-top:8px">Base: /api/v3</div>
|
||
<div style="margin-top:8px"><a class="btn soft" href="/docs" target="_blank" style="text-decoration:none;font-size:11px">\ud83d\udcc4 Swagger</a></div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--green)">
|
||
<h3 style="color:var(--green)">\ud83e\udd16 OpenAI Compatible</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">\u50cf\u8c03\u7528 GPT \u4e00\u6837\u63a7\u5236\u624b\u673a<br>\u652f\u6301\u6240\u6709 OpenAI SDK</div>
|
||
<div class="path" style="margin-top:8px">Base: /api/v3/gateway/v1</div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--purple)">
|
||
<h3 style="color:var(--purple)">\ud83e\udde9 MCP Protocol</h3>
|
||
<div style="font-size:11px;color:var(--sub);line-height:1.7;margin-top:6px">8 \u4e2a\u5de5\u5177\uff0c\u53ef\u88ab Claude/Cursor \u8c03\u7528<br>AI Agent \u539f\u751f\u96c6\u6210</div>
|
||
<div class="path" style="margin-top:8px">Base: /api/v3/gateway/mcp</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>\ud83d\udce1 \u6570\u636e\u6d41\u5411</h2><p class="sub">\u4ece\u5ba2\u6237\u7aef\u8bf7\u6c42\u5230\u8bbe\u5907\u6267\u884c\u7684\u5b8c\u6574\u94fe\u8def</p></div></div>
|
||
<div style="background:#0f172a;color:#e2e8f0;padding:20px;border-radius:14px;font-family:ui-monospace,monospace;font-size:12px;line-height:2;overflow-x:auto">
|
||
<div style="color:#7dd3fc">\u2588 \u5ba2\u6237\u7aef\u8bf7\u6c42\u6d41\u7a0b</div>
|
||
<div>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510</div>
|
||
<div>\u2502 <span style="color:#fbbf24">REST API</span> \u2502 \u2192 \u2502 <span style="color:#34d399">unified.py</span> \u2502 \u2192 \u2502 <span style="color:#a78bfa">\u901a\u9053\u9009\u62e9</span> \u2502 \u2192 \u2502 <span style="color:#f472b6">\u8bbe\u5907\u6267\u884c</span> \u2502</div>
|
||
<div>\u2502 OpenAI API \u2502 \u2502 \u53c2\u6570\u89e3\u6790 \u2502 \u2502 Hook/u2/ADB \u2502 \u2502 Agent/Frida\u2502</div>
|
||
<div>\u2502 MCP Tool \u2502 \u2502 \u901a\u9053\u8def\u7531 \u2502 \u2502 \u667a\u80fd\u964d\u7ea7 \u2502 \u2502 \u7ed3\u679c\u56de\u4f20 \u2502</div>
|
||
<div>\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518</div>
|
||
<div style="margin-top:10px;color:#7dd3fc">\u2588 \u8bbe\u5907\u5fc3\u8df3\u5faa\u73af (AI Brain)</div>
|
||
<div>\u6bcf 60s \u2192 \u68c0\u67e5\u8bbe\u5907\u72b6\u6001 \u2192 \u6709\u5f85\u529e? \u2192 AI \u51b3\u7b56\u6267\u884c \u2192 \u7ed3\u679c\u4e0a\u62a5</div>
|
||
<div>\u670d\u52a1\u5668\u65ad\u8fde \u2192 AI Brain \u81ea\u4e3b\u8fd0\u884c \u2192 \u91cd\u8fde\u540e\u4e0a\u4f20\u79bb\u7ebf\u7f13\u51b2</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>\ud83d\udee0\ufe0f \u6280\u672f\u6808</h2><p class="sub">\u670d\u52a1\u7aef\u3001\u8bbe\u5907\u7aef\u3001\u5bf9\u63a5\u5c42\u6280\u672f\u9009\u578b</p></div></div>
|
||
<div class="grid col3">
|
||
${card('\u670d\u52a1\u7aef','Python 3.11 + FastAPI + uvicorn<br>MongoDB + Redis<br>WebSocket Hub \u957f\u8fde\u63a5<br>Docker \u4e00\u952e\u90e8\u7f72')}
|
||
${card('\u8bbe\u5907\u7aef','Android Agent (Python/Termux)<br>Frida Hook \u6846\u67b6<br>uiautomator2 UI \u81ea\u52a8\u5316<br>AI Brain \u81ea\u4e3b\u51b3\u7b56')}
|
||
${card('\u5bf9\u63a5\u5c42','REST API 150+ \u7aef\u70b9<br>OpenAI Compatible \u7f51\u5173<br>MCP Protocol 8 \u5de5\u5177<br>PHP/TypeScript SDK')}
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>\ud83d\udcf1 \u652f\u6301\u5e73\u53f0</h2><p class="sub">\u5f53\u524d\u5df2\u652f\u6301\u548c\u8ba1\u5212\u652f\u6301\u7684\u5e94\u7528\u5e73\u53f0</p></div></div>
|
||
<div class="grid col2">
|
||
<div class="card" style="border-left:3px solid var(--green)">
|
||
<h3>\u2705 \u5df2\u652f\u6301</h3>
|
||
<div style="font-size:12px;color:var(--sub);line-height:2;margin-top:8px">
|
||
<div>\ud83d\udcac <b>\u5fae\u4fe1</b> \u2014 107 \u4e2a Hook \u64cd\u4f5c / 24 \u4e2a\u6a21\u5757 / \u5168\u529f\u80fd\u8986\u76d6</div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="border-left:3px solid var(--orange)">
|
||
<h3>\ud83d\udea7 \u5f00\u53d1\u4e2d</h3>
|
||
<div style="font-size:12px;color:var(--sub);line-height:2;margin-top:8px">
|
||
<div>\ud83d\udc9c <b>Soul</b> \u2014 \u6d3e\u5bf9/\u6d88\u606f/\u597d\u53cb\u81ea\u52a8\u5316</div>
|
||
<div>\ud83d\udcd5 <b>\u5c0f\u7ea2\u4e66</b> \u2014 \u7b14\u8bb0\u53d1\u5e03/\u4e92\u52a8/\u6d41\u91cf</div>
|
||
<div>\ud83c\udfb5 <b>\u6296\u97f3</b> \u2014 \u89c6\u9891\u53d1\u5e03/\u4e92\u52a8/\u7c89\u4e1d</div>
|
||
<div>\ud83d\udc1f <b>\u95f2\u9c7c</b> \u2014 \u5546\u54c1\u7ba1\u7406/\u6d88\u606f\u81ea\u52a8\u56de\u590d</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderBindSection(){
|
||
const wsProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const privateIp = (state.serverInfo?.ips || []).find(ip =>
|
||
/^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip)
|
||
);
|
||
const bindHost = privateIp || location.hostname;
|
||
const wsPort = state.serverInfo?.port
|
||
? `:${state.serverInfo.port}`
|
||
: (location.port ? `:${location.port}` : (location.protocol === 'https:' ? '' : ':8899'));
|
||
const defaultServer = state.bindingServer || `${wsProtocol}://${bindHost}${wsPort}/ws/device`;
|
||
return `
|
||
<div class="panel glass" id="bind-panel">
|
||
<div class="panel-head"><div><h2>📲 扫码绑定手机</h2><p class="sub">先扫描服务器,再用手机扫码;二维码只绑定当前服务器</p></div><span class="pill">首页唯一入口</span></div>
|
||
<div class="grid col2">
|
||
<div class="card">
|
||
<h3 style="margin-bottom:12px">绑定配置</h3>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<input id="bind-project" value="cunkebao" placeholder="项目 ID" style="width:100%;padding:9px 12px;border-radius:10px;border:1px solid var(--border);font-size:12px">
|
||
<input id="bind-name" value="工作手机" placeholder="项目名称" style="width:100%;padding:9px 12px;border-radius:10px;border:1px solid var(--border);font-size:12px">
|
||
<input id="bind-server" value="${defaultServer}" placeholder="扫描后自动填入服务器" style="width:100%;padding:9px 12px;border-radius:10px;border:1px solid var(--border);font-size:11px;font-family:monospace" readonly>
|
||
<button class="btn soft" onclick="scanLanDevices();setTimeout(()=>renderCurrentView(),600)">🔍 扫描服务器并作为绑定基准</button>
|
||
<button class="btn primary" onclick="generateBindQR()">生成绑定二维码</button>
|
||
<div style="font-size:11px;line-height:1.7;color:var(--muted)">授权关系:网页用管理员会话,存客宝用 API Key,手机用配对令牌。三者分开配置。</div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="display:flex;align-items:center;justify-content:center;min-height:240px">
|
||
<div id="bind-qr-area" style="text-align:center"><div style="font-size:36px;opacity:.3;margin-bottom:10px">📱</div><div style="color:var(--muted);font-size:12px">点击「生成绑定二维码」</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderConnectGuide(){
|
||
return `
|
||
<div style="padding:20px 0">
|
||
<div style="text-align:center;font-size:32px;margin-bottom:12px">📡</div>
|
||
<div style="text-align:center;font-weight:600;margin-bottom:16px">暂无设备连接</div>
|
||
<div style="color:var(--sub);font-size:12px;line-height:2">
|
||
<div style="background:rgba(124,58,237,.06);border-radius:12px;padding:14px">
|
||
<strong style="color:var(--purple)">扫码无线连接</strong><br>
|
||
① 打开手机端工作手机 APK<br>
|
||
② 扫描中台生成的设备绑定二维码<br>
|
||
③ Agent WebSocket 自动连接并接受远程控制
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ═══ 3. Engine Panel (AI Brain + Hook + Events) ═══ */
|
||
|
||
function renderEnginePanel(){
|
||
const tab = state.engineTab;
|
||
const devices = state.devices || [];
|
||
const modules = state.hookModules || [];
|
||
const currentModules = state.currentDeviceModules?.modules || [];
|
||
const events = state.hookEvents || [];
|
||
const deviceOptions = devices.map(d =>
|
||
`<option value="${d.device_id}" ${d.device_id===state.currentDevice?'selected':''}>${d.device_id} (${d.status||'offline'})</option>`
|
||
).join('');
|
||
|
||
const header = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>🧠 智能引擎</h2><p class="sub">AI Brain 自主决策 · Hook 模块控制 · 事件流监控</p></div>
|
||
<div class="btns"><button class="btn soft" onclick="refreshHook()">刷新数据</button></div>
|
||
</div>
|
||
<div class="engine-nav-groups">
|
||
<div class="engine-nav-group"><small>AI 中枢</small><div class="engine-nav-actions">
|
||
<div class="mode-btn ${tab==='brain'?'active':''}" onclick="state.engineTab='brain';renderCurrentView()">🧠 AI Brain</div>
|
||
</div></div>
|
||
<div class="engine-nav-group"><small>HOOK 能力</small><div class="engine-nav-actions">
|
||
<div class="mode-btn ${tab==='hook'?'active':''}" onclick="state.engineTab='hook';renderCurrentView()">🪝 模块</div>
|
||
<div class="mode-btn ${tab==='hookdata'?'active':''}" onclick="state.engineTab='hookdata';renderCurrentView()">📊 数据</div>
|
||
</div></div>
|
||
<div class="engine-nav-group"><small>运行与安全</small><div class="engine-nav-actions">
|
||
<div class="mode-btn ${tab==='events'?'active':''}" onclick="state.engineTab='events';renderCurrentView()">📡 事件 ${events.length}</div>
|
||
<div class="mode-btn ${tab==='connection'?'active':''}" onclick="state.engineTab='connection';refreshConnection();renderCurrentView()">🔌 连接</div>
|
||
<div class="mode-btn ${tab==='heartbeat'?'active':''}" onclick="state.engineTab='heartbeat';refreshHeartbeat();renderCurrentView()">💓 心跳</div>
|
||
<div class="mode-btn ${tab==='antiban'?'active':''}" onclick="state.engineTab='antiban';refreshAntiban();renderCurrentView()">🛡️ 防封</div>
|
||
</div></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
let content = '';
|
||
|
||
if (tab === 'brain') {
|
||
const sr = state.skillRegistry || {};
|
||
const srSkills = sr.skills || {};
|
||
const srSummary = sr.summary || {};
|
||
const brainDash = state.brainDashboard || {};
|
||
const sub = state.brainSub || 'overview';
|
||
const sf = state.brainSkillFilter || 'all';
|
||
|
||
const subTabs = `
|
||
<div class="mode-tabs" style="margin-top:12px">
|
||
<div class="mode-btn ${sub==='overview'?'active':''}" onclick="state.brainSub='overview';renderCurrentView()">📊 总览</div>
|
||
<div class="mode-btn ${sub==='skills'?'active':''}" onclick="state.brainSub='skills';renderCurrentView()">📦 技能库</div>
|
||
<div class="mode-btn ${sub==='console'?'active':''}" onclick="state.brainSub='console';renderCurrentView()">⚡ 脚本执行</div>
|
||
<div class="mode-btn ${sub==='tasks'?'active':''}" onclick="state.brainSub='tasks';renderCurrentView()">📋 任务管理</div>
|
||
<div class="mode-btn ${sub==='api'?'active':''}" onclick="state.brainSub='api';renderCurrentView()">📖 API</div>
|
||
</div>`;
|
||
|
||
let subContent = '';
|
||
|
||
if (sub === 'overview') {
|
||
subContent = `
|
||
<div class="stats" style="grid-template-columns:repeat(4,1fr);margin-top:16px">
|
||
<div class="stat glass"><div class="k">技能模块</div><div class="v">${srSummary.total_skills||0}</div><div class="d">${srSummary.total_modules||0} 个子模块</div></div>
|
||
<div class="stat glass"><div class="k">可用操作</div><div class="v">${srSummary.total_actions||0}</div><div class="d">全部 action</div></div>
|
||
<div class="stat glass"><div class="k">WS 在线</div><div class="v">${(brainDash.devices||{}).ws_online||0}</div><div class="d">ADB: ${(brainDash.devices||{}).adb_online||0}</div></div>
|
||
<div class="stat glass"><div class="k">AI Brain</div><div class="v">v${(brainDash.ai_brain||{}).version||'1.0'}</div><div class="d">${(brainDash.ai_brain||{}).architecture||'v3.1'}</div></div>
|
||
</div>
|
||
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head"><div><h2>执行通道</h2><p class="sub">4 层通道自动降级 · Hook → u2 → ADB → AI Agent</p></div></div>
|
||
<div class="grid col4">
|
||
${card('🧩 微信注入能力 <span style="color:var(--green);font-size:11px">P1</span>','微信深度数据与接口能力<br>服务状态在设备高级连接中统一查看<br>需 Root + 注入引擎')}
|
||
${card('🖱️ u2 自动化 <span style="color:var(--blue);font-size:11px">P2</span>','UI 模拟操作<br>2-15s · 通用性强<br>无需 Root')}
|
||
${card('⌨️ 设备底座 <span style="color:var(--orange);font-size:11px">P3</span>','ADB 作为安装与运维兜底<br>截图、点击、输入与系统操作<br>无线主控时保持可选')}
|
||
${card('🧠 AI Agent <span style="color:var(--purple);font-size:11px">P4</span>','LLM 意图解析<br>自然语言 → 步骤执行<br>依赖通道 1/2/3')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head"><div><h2>AI Brain 能力</h2><p class="sub">心跳驱动自主决策 · 离线续航 · 任务队列</p></div></div>
|
||
<div class="grid col3">
|
||
${card('🧠 自主决策','每 60s 检查设备状态<br>LLM 分析待办事项<br>自动执行最优操作')}
|
||
${card('📡 离线模式','服务器断连后<br>AI Brain 独立运行<br>结果缓冲待上传')}
|
||
${card('📋 任务队列','服务器推送 + AI 自生<br>优先级排序执行<br>常驻指令定时触发')}
|
||
${card('🛡️ 防封引擎','拟人化操作行为<br>频率限制+内容差异化<br>设备环境自检')}
|
||
${card('🔄 自动重连','指数退避重连<br>2s→4s→8s→30s<br>断网自动恢复')}
|
||
${card('📊 状态上报','心跳+详细报告<br>电量/网络/APP 状态<br>异常自动告警')}
|
||
</div>
|
||
</div>`;
|
||
|
||
} else if (sub === 'skills') {
|
||
const skillKeys = Object.keys(srSkills);
|
||
const filterBtns = [['all','全部'],...skillKeys.map(k=>[k, (srSkills[k]||{}).icon+' '+(srSkills[k]||{}).name])].map(([k,label])=>
|
||
`<div class="mode-btn ${sf===k?'active':''}" onclick="state.brainSkillFilter='${k}';renderCurrentView()" style="font-size:12px">${label}</div>`
|
||
).join('');
|
||
|
||
const filtered = sf==='all' ? Object.entries(srSkills) : Object.entries(srSkills).filter(([k])=>k===sf);
|
||
const skillCards = filtered.map(([key, skill]) => {
|
||
const mods = Object.entries(skill.modules || {});
|
||
const actionCount = mods.reduce((s,[_,a])=>s+a.length,0);
|
||
const colors = {wechat:'var(--green)',douyin:'var(--purple)',xhs:'var(--red)',xianyu:'var(--teal)',soul:'var(--orange)',system:'var(--blue)',hook:'var(--orange)',ai_brain:'var(--purple)',anti_ban:'var(--green)'};
|
||
const modList = mods.map(([modName, actions]) =>
|
||
`<div style="margin-top:10px"><div style="font-size:11px;color:var(--sub);font-weight:700;margin-bottom:5px;text-transform:uppercase;letter-spacing:.03em">${modName} <span style="color:var(--muted);font-weight:500">(${actions.length})</span></div><div style="display:flex;flex-wrap:wrap;gap:5px">${actions.map(a=>`<span style="background:rgba(37,99,235,.07);color:var(--blue);padding:4px 10px;border-radius:8px;font-size:11px;font-weight:500;cursor:pointer;border:1px solid rgba(37,99,235,.1);transition:.15s" onmouseover="this.style.background='rgba(37,99,235,.15)'" onmouseout="this.style.background='rgba(37,99,235,.07)'" onclick="state.brainSub='console';fillBrainScript('${key}','${a}');renderCurrentView()">${a}</span>`).join('')}</div></div>`
|
||
).join('');
|
||
return `<div class="card" style="border-left:3px solid ${colors[key]||'var(--sub)'}">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<div><span style="font-size:22px;margin-right:8px">${skill.icon||'📦'}</span><b style="font-size:16px">${skill.name}</b></div>
|
||
<span style="background:rgba(37,99,235,.08);color:var(--blue);padding:3px 10px;border-radius:8px;font-size:11px;font-weight:700">${actionCount} actions</span>
|
||
</div>
|
||
${skill.package ? `<div style="font-size:11px;color:var(--muted);margin-top:3px;font-family:ui-monospace,monospace">${skill.package}</div>` : ''}
|
||
${modList}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
subContent = `
|
||
<div style="margin-top:14px">
|
||
<div class="mode-tabs" style="flex-wrap:wrap">${filterBtns}</div>
|
||
</div>
|
||
<div class="grid ${sf==='all'?'col2':''}" style="margin-top:14px">${skillCards || '<div class="empty" style="text-align:center;padding:40px;color:var(--muted)">加载中... 点击上方刷新</div>'}</div>`;
|
||
|
||
} else if (sub === 'console') {
|
||
const presets = [
|
||
{cat:'微信 · 消息', items:[
|
||
{label:'发消息', s:'wechat', a:'send_message', p:'{"to_id":"联系人名","content":"你好,在吗?"}'},
|
||
{label:'群发消息', s:'wechat', a:'batch_send_message', p:'{"user_ids":["张三","李四"],"content":"通知:明天下午3点开会"}'},
|
||
{label:'获取消息', s:'wechat', a:'get_messages', p:'{"limit":3000}'},
|
||
{label:'转发消息', s:'wechat', a:'forward_message', p:'{"to_id":"李四","content":"转发内容"}'},
|
||
{label:'发名片', s:'wechat', a:'send_card', p:'{"to_id":"张三","card_wxid":"wxid_xxx"}'},
|
||
{label:'发表情', s:'wechat', a:'send_emoji', p:'{"user_id":"张三","emoji_name":"微笑"}'},
|
||
{label:'发语音', s:'wechat', a:'send_voice_message', p:'{"user_id":"张三","duration":3}'},
|
||
{label:'发位置', s:'wechat', a:'send_location', p:'{"user_id":"张三"}'},
|
||
{label:'发文件', s:'wechat', a:'send_file_from_chat', p:'{"user_id":"张三"}'},
|
||
]},
|
||
{cat:'微信 · 好友', items:[
|
||
{label:'添加好友', s:'wechat', a:'add_friend', p:'{"user_id":"微信号","message":"你好,我是XXX"}'},
|
||
{label:'通过好友', s:'wechat', a:'accept_friend', p:'{}'},
|
||
{label:'批量加好友', s:'wechat', a:'batch_add_friend', p:'{"user_ids":["wxid1","wxid2"],"message":"你好"}'},
|
||
{label:'获取联系人', s:'wechat', a:'get_contacts', p:'{"limit":10000}'},
|
||
{label:'搜索联系人', s:'wechat', a:'search_contact', p:'{"keyword":"张三"}'},
|
||
{label:'好友详情', s:'wechat', a:'get_friend_info', p:'{"user_id":"wxid_xxx"}'},
|
||
{label:'设置备注', s:'wechat', a:'set_remark', p:'{"user_id":"wxid_xxx","remark":"客户-张三"}'},
|
||
{label:'删除好友', s:'wechat', a:'delete_friend', p:'{"user_id":"wxid_xxx"}'},
|
||
]},
|
||
{cat:'微信 · 群聊', items:[
|
||
{label:'创建群', s:'wechat', a:'create_group', p:'{"group_name":"工作群","member_ids":["张三","李四"]}'},
|
||
{label:'邀请入群', s:'wechat', a:'invite_to_group', p:'{"group_id":"群名","member_ids":["王五"]}'},
|
||
{label:'群发消息', s:'wechat', a:'send_group_message', p:'{"group_id":"群名","content":"大家好"}'},
|
||
{label:'设置群公告', s:'wechat', a:'set_group_notice', p:'{"group_id":"群名","notice":"本周五聚餐"}'},
|
||
{label:'获取群列表', s:'wechat', a:'get_groups', p:'{"limit":500}'},
|
||
{label:'群成员', s:'wechat', a:'get_group_members', p:'{"group_id":"群名"}'},
|
||
{label:'群欢迎语', s:'wechat', a:'set_group_welcome', p:'{"group_id":"群名","welcome_text":"欢迎加入!"}'},
|
||
{label:'踢人', s:'wechat', a:'remove_from_group', p:'{"group_id":"群名","member_ids":["xxx"]}'},
|
||
]},
|
||
{cat:'微信 · 朋友圈', items:[
|
||
{label:'发朋友圈', s:'wechat', a:'post_moments', p:'{"content":"今天天气真好!"}'},
|
||
{label:'点赞', s:'wechat', a:'like_moments', p:'{"user_id":"张三","post_index":0}'},
|
||
{label:'查看朋友圈', s:'wechat', a:'get_moments', p:'{"limit":10}'},
|
||
{label:'删除朋友圈', s:'wechat', a:'delete_moments', p:'{"post_index":0}'},
|
||
{label:'转发链接', s:'wechat', a:'forward_moments_link', p:'{"url":"https://...","title":"好文分享"}'},
|
||
{label:'设置权限', s:'wechat', a:'set_moments_privacy', p:'{"days":180}'},
|
||
]},
|
||
{cat:'微信 · 标签', items:[
|
||
{label:'创建标签', s:'wechat', a:'create_tag', p:'{"tag_name":"VIP客户"}'},
|
||
{label:'添加标签', s:'wechat', a:'add_tag', p:'{"user_id":"wxid_xxx","tags":["VIP客户"]}'},
|
||
{label:'查看标签', s:'wechat', a:'get_tags', p:'{}'},
|
||
{label:'按标签查人', s:'wechat', a:'get_users_by_tag', p:'{"tag_name":"VIP客户"}'},
|
||
]},
|
||
{cat:'微信 · 个人', items:[
|
||
{label:'查看资料', s:'wechat', a:'get_profile', p:'{}'},
|
||
{label:'改昵称', s:'wechat', a:'set_nickname', p:'{"nickname":"新昵称"}'},
|
||
{label:'改签名', s:'wechat', a:'set_signature', p:'{"signature":"新签名"}'},
|
||
{label:'改头像', s:'wechat', a:'set_avatar', p:'{}'},
|
||
{label:'置顶聊天', s:'wechat', a:'set_chat_top', p:'{"user_id":"张三","enable":true}'},
|
||
{label:'免打扰', s:'wechat', a:'set_mute_chat', p:'{"user_id":"张三","enable":true}'},
|
||
{label:'发红包', s:'wechat', a:'send_red_packet', p:'{"to_id":"张三","amount":"1.00","message":"恭喜发财"}'},
|
||
]},
|
||
{cat:'抖音 / 小红书 / 闲鱼 / Soul', items:[
|
||
{label:'抖音发消息', s:'douyin', a:'send_message', p:'{"to_id":"用户","content":"你好"}'},
|
||
{label:'抖音粉丝', s:'douyin', a:'get_fans', p:'{}'},
|
||
{label:'抖音回复', s:'douyin', a:'reply_comment', p:'{}'},
|
||
{label:'小红书发消息', s:'xhs', a:'send_message', p:'{"to_id":"用户","content":"你好"}'},
|
||
{label:'小红书点赞', s:'xhs', a:'like_note', p:'{}'},
|
||
{label:'闲鱼发消息', s:'xianyu', a:'send_message', p:'{"to_id":"买家","content":"你好"}'},
|
||
{label:'Soul发消息', s:'soul', a:'send_message', p:'{"to_id":"用户","content":"你好"}'},
|
||
{label:'Soul发动态', s:'soul', a:'post_moments', p:'{"content":"今日心情"}'},
|
||
]},
|
||
{cat:'系统控制', items:[
|
||
{label:'截屏', s:'system', a:'screenshot', p:'{}'},
|
||
{label:'打开微信', s:'system', a:'app_start', p:'{"package":"com.tencent.mm"}'},
|
||
{label:'打开抖音', s:'system', a:'app_start', p:'{"package":"com.ss.android.ugc.aweme"}'},
|
||
{label:'关闭应用', s:'system', a:'app_stop', p:'{"package":"com.tencent.mm"}'},
|
||
{label:'设备信息', s:'system', a:'device_info', p:'{}'},
|
||
{label:'回主页', s:'system', a:'press_key', p:'{"key":"home"}'},
|
||
{label:'返回', s:'system', a:'press_key', p:'{"key":"back"}'},
|
||
{label:'点击坐标', s:'system', a:'click', p:'{"x":540,"y":1200}'},
|
||
{label:'UI树', s:'system', a:'ui_tree', p:'{}'},
|
||
{label:'恢复网络', s:'system', a:'reconnect_network', p:'{}'},
|
||
]},
|
||
];
|
||
|
||
const presetHtml = presets.map(g => `
|
||
<div style="margin-bottom:14px">
|
||
<div style="font-size:11.5px;font-weight:700;color:var(--sub);margin-bottom:6px;text-transform:uppercase;letter-spacing:.03em">${g.cat}</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">${g.items.map(p =>
|
||
`<button class="btn soft" style="font-size:11px;padding:5px 12px;border-radius:8px" onclick="fillBrainPreset('${p.s}','${p.a}',${JSON.stringify(JSON.stringify(p.p))})">${p.label}</button>`
|
||
).join('')}</div>
|
||
</div>`).join('');
|
||
|
||
subContent = `
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head">
|
||
<div><h2>⚡ 脚本执行</h2><p class="sub">选择设备 · 点击预设指令或手动填写 · 发送到设备执行</p></div>
|
||
<select id="aibrain-device" onchange="state.currentDevice=this.value" style="padding:8px 12px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px">${deviceOptions}</select>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:12px">
|
||
<div><label style="font-size:11px;color:var(--sub);font-weight:600;display:block;margin-bottom:4px">Script</label><input id="brain-script" placeholder="wechat / douyin / system" style="width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px"></div>
|
||
<div><label style="font-size:11px;color:var(--sub);font-weight:600;display:block;margin-bottom:4px">Action</label><input id="brain-action" placeholder="send_message / get_contacts" style="width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px"></div>
|
||
<div><label style="font-size:11px;color:var(--sub);font-weight:600;display:block;margin-bottom:4px">Channel</label><select id="brain-channel" style="width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px"><option value="auto">auto (智能选择)</option><option value="hook">hook (Frida优先)</option><option value="u2">u2 (UI自动化)</option></select></div>
|
||
</div>
|
||
<div style="margin-bottom:12px"><label style="font-size:11px;color:var(--sub);font-weight:600;display:block;margin-bottom:4px">Params (JSON)</label><textarea id="brain-params" rows="3" placeholder='{"to_id": "张三", "content": "你好"}' style="width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px;resize:vertical;font-family:ui-monospace,monospace"></textarea></div>
|
||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||
<button class="btn primary" onclick="brainExecuteScript()">⚡ 执行</button>
|
||
<button class="btn soft" onclick="brainBatchExecute()">📡 批量执行</button>
|
||
<button class="btn soft" onclick="aiBrainLoadStatus()">🔍 Brain 状态</button>
|
||
</div>
|
||
<div id="aibrain-result" style="background:#0f172a;border-radius:var(--radius-sm);padding:16px;min-height:80px;max-height:320px;overflow:auto;font-size:12px;line-height:1.75;white-space:pre-wrap;color:#cbd5e1;font-family:ui-monospace,SFMono-Regular,monospace"><span style="color:#7dd3fc">// 就绪 · 点击下方预设指令 或 手动填写后执行</span></div>
|
||
</div>
|
||
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head"><div><h2>📋 预设指令库</h2><p class="sub">点击任意指令自动填入控制台 · 覆盖微信全功能 + 多平台 + 系统控制</p></div></div>
|
||
${presetHtml}
|
||
</div>`;
|
||
|
||
} else if (sub === 'tasks') {
|
||
subContent = `
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head">
|
||
<div><h2>📋 AI 任务推送</h2><p class="sub">向设备 AI Brain 推送自然语言任务 · AI 自主拆解执行</p></div>
|
||
<select id="aibrain-device" onchange="state.currentDevice=this.value" style="padding:8px 12px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px">${deviceOptions}</select>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap">
|
||
<button class="btn primary" onclick="aiBrainPushTask()">📋 推送 AI 任务</button>
|
||
<button class="btn soft" onclick="aiBrainPushOrder()">📌 推送常驻指令</button>
|
||
<button class="btn soft" onclick="aiBrainExecute()">⚡ AI 自然语言执行</button>
|
||
<button class="btn soft" onclick="aiBrainLoadStatus()">🔍 查看 Brain 状态</button>
|
||
</div>
|
||
<div id="aibrain-result" style="background:#0f172a;border-radius:var(--radius-sm);padding:16px;min-height:80px;max-height:320px;overflow:auto;font-size:12px;line-height:1.75;white-space:pre-wrap;color:#cbd5e1;font-family:ui-monospace,SFMono-Regular,monospace"><span style="color:#7dd3fc">// 任务管理就绪</span></div>
|
||
</div>
|
||
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head"><div><h2>📌 常驻指令</h2><p class="sub">心跳周期自动触发 · 离线时 AI Brain 自主执行</p></div></div>
|
||
<div class="grid col3">
|
||
${card('📬 检查未读','自动检查微信未读消息<br>阅读并标记已读<br><code style="font-size:10px">wechat.get_messages</code>')}
|
||
${card('🤝 通过好友','自动通过新的好友请求<br>可附带欢迎语<br><code style="font-size:10px">wechat.accept_friend</code>')}
|
||
${card('💚 保活微信','检查微信运行状态<br>未运行则自动启动<br><code style="font-size:10px">system.app_start</code>')}
|
||
${card('🛡️ 弹窗守护','每5秒扫描系统弹窗<br>自动处理权限/USB授权<br><code style="font-size:10px">system.dismiss_popups</code>')}
|
||
${card('🌐 网络恢复','检测断网后自动重连<br>WiFi/4G 智能切换<br><code style="font-size:10px">system.reconnect</code>')}
|
||
${card('🌱 养号计划','新号/成长/成熟三阶段<br>自动执行养号动作<br><code style="font-size:10px">anti_ban.nurture</code>')}
|
||
${card('📊 朋友圈互动','定时浏览好友朋友圈<br>随机点赞+评论<br><code style="font-size:10px">wechat.like_moments</code>')}
|
||
${card('🏷️ 标签整理','新好友自动打标签<br>按来源/地区分类<br><code style="font-size:10px">wechat.add_tag</code>')}
|
||
${card('📱 设备状态上报','电量/网络/APP 状态<br>异常自动告警<br><code style="font-size:10px">system.status</code>')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head"><div><h2>🤖 AI 自然语言任务示例</h2><p class="sub">直接用中文描述任务 · AI Brain 自动拆解为技能执行链</p></div></div>
|
||
<div class="grid col2">
|
||
${card('💬 客户跟进','打开微信,找到张三,<br>发消息说"上次谈的方案已经确认,<br>方便的话约个时间聊聊"')}
|
||
${card('📢 群发通知','给所有VIP客户群发消息<br>"本周六下午2点线下沙龙,<br>欢迎参加"')}
|
||
${card('🔍 数据采集','打开微信,获取最近20条<br>未读消息,整理联系人信息,<br>按标签分类统计')}
|
||
${card('🌅 朋友圈营销','发一条朋友圈<br>"新品上市,限时优惠"<br>然后浏览好友动态点赞')}
|
||
${card('👋 新人欢迎','通过所有待处理的好友请求<br>给每个新好友发欢迎语<br>并打上"新客户"标签')}
|
||
${card('📊 日报汇总','截屏当前微信页面<br>获取今日消息统计<br>获取好友列表变化')}
|
||
</div>
|
||
</div>`;
|
||
|
||
} else if (sub === 'api') {
|
||
subContent = `
|
||
<div class="panel glass" style="margin-top:16px">
|
||
<div class="panel-head"><div><h2>🔌 API 端点</h2><p class="sub">AI Brain 全部 REST API</p></div></div>
|
||
<div style="font-size:13px;line-height:2.2;padding:8px 0">
|
||
<div style="font-weight:700;color:var(--text);margin-bottom:4px">AI Brain 核心</div>
|
||
<code style="background:rgba(37,99,235,.08);padding:3px 8px;border-radius:6px;margin-right:6px">GET</code> /api/v3/ai/brain/skill-registry — 技能注册表<br>
|
||
<code style="background:rgba(37,99,235,.08);padding:3px 8px;border-radius:6px;margin-right:6px">GET</code> /api/v3/ai/brain/dashboard — 仪表盘<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/ai/brain/execute-script — 脚本执行<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/ai/brain/batch-execute — 批量执行<br>
|
||
<div style="font-weight:700;color:var(--text);margin:12px 0 4px">设备 AI Brain</div>
|
||
<code style="background:rgba(37,99,235,.08);padding:3px 8px;border-radius:6px;margin-right:6px">GET</code> /api/v3/devices/{id}/ai/status — 设备 Brain 状态<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/devices/{id}/ai/task — 推送任务<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/devices/{id}/ai/standing-order — 常驻指令<br>
|
||
<code style="background:rgba(245,158,11,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/devices/{id}/ai/execute — 同步执行<br>
|
||
<div style="font-weight:700;color:var(--text);margin:12px 0 4px">AI 对话</div>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/ai/chat — AI 对话控制<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/v1/chat/completions — OpenAI 兼容<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:3px 8px;border-radius:6px;margin-right:6px">POST</code> /api/v3/agent/execute — 自然语言执行<br>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>🧠 AI Brain</h2><p class="sub">全设备统一控制中枢 · ${srSummary.total_skills||0} 技能 / ${srSummary.total_actions||0} 操作</p></div>
|
||
<div class="btns"><button class="btn primary" onclick="loadBrainDashboard()">🔄 刷新</button></div>
|
||
</div>
|
||
${subTabs}
|
||
</div>
|
||
${subContent}`;
|
||
|
||
} else if (tab === 'hook') {
|
||
content = `
|
||
<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(m => `
|
||
<tr>
|
||
<td>${esc(m.name || m.module_id)}</td>
|
||
<td>${esc(m.version || '-')}</td>
|
||
<td>${m.enabled ? '启用' : '停用'}</td>
|
||
<td>${esc((m.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>执行通道优先级</h2><p class="sub">Agent v3.1 的通道选择策略:优先 Hook → 降级 u2</p></div></div>
|
||
<div class="grid col2">
|
||
${card('🔗 通道 1:Frida Hook(优先)','直接操作 APP 内部函数<br>发消息/读联系人/群管理<br>速度快、不触发 UI、隐蔽性高')}
|
||
${card('🖱️ 通道 2:uiautomator2(兜底)','UI 自动化操作<br>点击/滑动/输入/截图<br>Frida 不可用时自动降级')}
|
||
</div>
|
||
<div class="grid col2" style="margin-top:10px">
|
||
${card('🧠 通道 3:AI Agent(增强)','自然语言 → 步骤拆解 → 执行<br>依赖通道 1 或 2')}
|
||
${card('📺 通道 4:scrcpy(人工)','投屏 + 人工操作<br>调试和特殊场景用')}
|
||
</div>
|
||
</div>`;
|
||
|
||
} else if (tab === 'hookdata') {
|
||
const hd = state.hookData || {};
|
||
const hdLoading = state.hookDataLoading;
|
||
const profile = hd.profile?.profile || hd.profile || {};
|
||
const contacts = hd.contacts?.contacts || [];
|
||
const groups = hd.groups?.groups || [];
|
||
const labels = hd.labels?.labels || [];
|
||
const messages = hd.messages?.messages || [];
|
||
const deviceInfo = hd.device_info?.device || {};
|
||
const contactTotal = hd.contacts?.total_count || hd.contacts?._diag?.filtered_total || hd.contacts?.count || contacts.length || 0;
|
||
const contactLoaded = contacts.length || 0;
|
||
const messageTotal = hd.messages?.total_count || hd.messages?.count || messages.length || 0;
|
||
const messageLoaded = messages.length || 0;
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>Hook 实时数据</h2><p class="sub">通过 Frida Hook 直连设备,实时读取微信数据</p></div>
|
||
<div class="btns">
|
||
<select id="hookdata-device" onchange="state.currentDevice=this.value" style="padding:8px 12px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px">${deviceOptions}</select>
|
||
<button class="btn primary" onclick="fetchHookData()" ${hdLoading?'disabled':''}>${hdLoading ? '加载中...' : '🔄 拉取数据'}</button>
|
||
</div>
|
||
</div>
|
||
${!hd.profile && !hdLoading ? '<div style="text-align:center;padding:40px 0;color:var(--muted);font-size:14px">选择设备后点击「拉取数据」获取 Hook 实时数据</div>' : ''}
|
||
${hd.profile ? `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:16px">
|
||
<div class="card" style="border-left:3px solid var(--blue)">
|
||
<div style="font-size:11px;color:var(--sub);font-weight:600">微信资料</div>
|
||
<div style="font-size:18px;font-weight:800;margin-top:4px">${esc(profile.nickname || '-')}</div>
|
||
<div style="font-size:12px;color:var(--sub);margin-top:4px">wxid: ${esc(profile.wxid || '-')}</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-top:2px">版本: ${esc(profile.wechat_version || '-')} · 签名: ${esc(profile.signature || '-')}</div>
|
||
</div>
|
||
<div class="card" style="border-left:3px solid var(--green)">
|
||
<div style="font-size:11px;color:var(--sub);font-weight:600">数据统计</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
|
||
<div><div style="font-size:22px;font-weight:800;color:var(--green)">${contactTotal}</div><div style="font-size:10px;color:var(--muted)">联系人${contactLoaded && contactLoaded !== contactTotal ? ` · 已载 ${contactLoaded}` : ''}</div></div>
|
||
<div><div style="font-size:22px;font-weight:800;color:var(--blue)">${groups.length || hd.groups?.count || 0}</div><div style="font-size:10px;color:var(--muted)">群聊</div></div>
|
||
<div><div style="font-size:22px;font-weight:800;color:var(--purple)">${labels.length || hd.labels?.count || 0}</div><div style="font-size:10px;color:var(--muted)">标签</div></div>
|
||
<div><div style="font-size:22px;font-weight:800;color:var(--orange)">${messageTotal}</div><div style="font-size:10px;color:var(--muted)">近期消息${messageLoaded && messageLoaded !== messageTotal ? ` · 已载 ${messageLoaded}` : ''}</div></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="border-left:3px solid var(--teal)">
|
||
<div style="font-size:11px;color:var(--sub);font-weight:600">设备信息</div>
|
||
<div style="font-size:13px;margin-top:6px;line-height:1.8">
|
||
${deviceInfo.model ? `机型: <b>${esc(deviceInfo.model)}</b><br>` : ''}
|
||
${deviceInfo.brand ? `品牌: ${esc(deviceInfo.brand)}<br>` : ''}
|
||
${deviceInfo.android_version ? `Android: ${esc(deviceInfo.android_version)} (SDK ${deviceInfo.sdk_int || ''})<br>` : ''}
|
||
${!deviceInfo.model ? '<span style="color:var(--muted)">点击拉取数据</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||
<h3 style="font-size:14px;font-weight:700">👥 联系人 (${contactTotal}${contactLoaded && contactLoaded !== contactTotal ? ` / 已载 ${contactLoaded}` : ''})</h3>
|
||
<input type="text" id="hook-contact-search" placeholder="搜索..." oninput="filterHookTable('contacts')" style="padding:5px 10px;border-radius:8px;border:1px solid var(--border);font-size:12px;width:140px;background:var(--panel)">
|
||
</div>
|
||
<div style="max-height:360px;overflow-y:auto;scrollbar-width:thin" id="hook-contacts-list">
|
||
<table class="list-table" style="font-size:12px">
|
||
<thead><tr><th>wxid</th><th>昵称</th><th>备注</th></tr></thead>
|
||
<tbody>
|
||
${contacts.length ? contacts.slice(0,100).map(c => `
|
||
<tr class="hook-contact-row"><td style="font-family:monospace;font-size:11px">${esc((c.wxid||'').substring(0,20))}</td><td>${esc(c.nickname||'-')}</td><td>${esc(c.remark||'')}</td></tr>
|
||
`).join('') : '<tr><td colspan="3" class="empty">暂无联系人</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||
<h3 style="font-size:14px;font-weight:700">👥 群聊 (${groups.length})</h3>
|
||
<input type="text" id="hook-group-search" placeholder="搜索..." oninput="filterHookTable('groups')" style="padding:5px 10px;border-radius:8px;border:1px solid var(--border);font-size:12px;width:140px;background:var(--panel)">
|
||
</div>
|
||
<div style="max-height:360px;overflow-y:auto;scrollbar-width:thin" id="hook-groups-list">
|
||
<table class="list-table" style="font-size:12px">
|
||
<thead><tr><th>群ID</th><th>群名</th><th>成员数</th></tr></thead>
|
||
<tbody>
|
||
${groups.length ? groups.slice(0,100).map(g => `
|
||
<tr class="hook-group-row"><td style="font-family:monospace;font-size:11px">${esc((g.chatroomId||g.wxid||'').substring(0,22))}</td><td>${esc(g.nickname||g.name||'-')}</td><td>${g.memberCount||g.member_count||'-'}</td></tr>
|
||
`).join('') : '<tr><td colspan="3" class="empty">暂无群聊</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
|
||
<div class="card">
|
||
<h3 style="font-size:14px;font-weight:700;margin-bottom:10px">🏷️ 标签 (${labels.length})</h3>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
${labels.length ? labels.map(l => `<span class="pill" style="background:rgba(124,58,237,.08);color:var(--purple);border-color:rgba(124,58,237,.12)">${esc(l.name||l.labelName||'未命名')} <small style="opacity:.6">(${l.count||l.contactCount||0})</small></span>`).join('') : '<span style="color:var(--muted);font-size:12px">暂无标签</span>'}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 style="font-size:14px;font-weight:700;margin-bottom:10px">💬 最近消息</h3>
|
||
<div style="max-height:200px;overflow-y:auto;scrollbar-width:thin">
|
||
${messages.length ? messages.slice(0,15).map(m => `
|
||
<div style="padding:6px 0;border-bottom:1px solid rgba(0,0,0,.04);font-size:12px">
|
||
<span style="color:var(--blue);font-weight:600">${esc((m.talker||m.from||'').substring(0,16))}</span>
|
||
<span style="color:var(--muted);margin:0 6px">·</span>
|
||
<span>${esc((m.content||'').substring(0,60))}</span>
|
||
</div>
|
||
`).join('') : '<div style="color:var(--muted);font-size:12px">暂无消息</div>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>`;
|
||
|
||
} else if (tab === 'events') {
|
||
content = `
|
||
<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>`;
|
||
|
||
} else if (tab === 'connection') {
|
||
const protocol = state.protocol || {};
|
||
const connection = state.connection || {};
|
||
const handshake = protocol.handshake || {};
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>连接入口</h2><p class="sub">WebSocket Hub、Hook 事件流和 REST API 的实时端点</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>`;
|
||
|
||
} else if (tab === 'heartbeat') {
|
||
const hb = state.heartbeatDashboard || {};
|
||
const hbDevices = hb.devices || {};
|
||
const hbEvents = state.heartbeatEvents || [];
|
||
const deviceIds = Object.keys(hbDevices);
|
||
const healthColor = s => s >= 80 ? 'var(--green)' : s >= 50 ? 'var(--orange)' : 'var(--red)';
|
||
const healthLabel = s => s >= 80 ? '健康' : s >= 50 ? '注意' : '异常';
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>心跳监控</h2><p class="sub">AI 驱动的设备健康评分 · 异常检测 · 自动恢复</p></div></div>
|
||
<div class="stats" style="grid-template-columns:repeat(4,1fr);margin-bottom:8px">
|
||
${statCard('监控设备', deviceIds.length, '实时健康评分')}
|
||
${statCard('平均分', deviceIds.length ? Math.round(deviceIds.reduce((s,id)=>s+(hbDevices[id]?.score||0),0)/deviceIds.length) : '-', '满分 100')}
|
||
${statCard('告警事件', hbEvents.length, '最近记录')}
|
||
${statCard('监控周期', (hb.interval_sec || 10) + 's', '自动巡检')}
|
||
</div>
|
||
</div>
|
||
${deviceIds.length ? `<div class="panel glass"><div class="panel-head"><div><h2>设备健康</h2></div></div><div class="grid col2">${deviceIds.map(id => {
|
||
const d = hbDevices[id]; const score = d.score ?? 0;
|
||
return `<div class="card" style="position:relative;overflow:hidden"><div style="position:absolute;top:0;left:0;right:0;height:3px;background:rgba(15,23,42,.04)"><div style="height:100%;width:${score}%;background:${healthColor(score)};border-radius:0 2px 2px 0;transition:width .5s"></div></div><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-top:4px"><div><h3 style="font-size:14px">${esc(id.substring(0,16))}${id.length>16?'…':''}</h3><div style="font-size:11px;color:var(--sub);margin-top:2px">上次心跳: ${d.last_heartbeat ? new Date(d.last_heartbeat).toLocaleTimeString('zh-CN',{hour12:false}) : '-'} · ${d.heartbeat_age_sec || 0}s 前</div></div><div style="text-align:right"><div class="health-score" style="color:${healthColor(score)}">${score}</div><div class="health-label">${healthLabel(score)}</div></div></div><div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:12px;font-size:11.5px"><div><span style="color:var(--sub)">电量</span><br><b>${d.battery_level >= 0 ? d.battery_level + '%' : '-'}</b>${d.battery_charging ? ' ⚡' : ''}</div><div><span style="color:var(--sub)">内存</span><br><b>${d.memory_usage_pct > 0 ? d.memory_usage_pct.toFixed(1) + '%' : '-'}</b></div><div><span style="color:var(--sub)">存储</span><br><b>${d.storage_free_mb > 0 ? d.storage_free_mb + 'MB' : '-'}</b></div><div><span style="color:var(--sub)">网络</span><br><b>${esc(d.network_type || '-')}</b></div></div><div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px"><span class="mode-badge ${d.ws_connected ? 'available' : 'unavailable'}"><span class="badge-dot"></span>WS</span><span class="mode-badge ${d.adb_connected ? 'available' : 'unavailable'}"><span class="badge-dot"></span>ADB</span><span class="mode-badge ${d.hook_available ? 'available' : 'unavailable'}"><span class="badge-dot"></span>Hook</span><span class="mode-badge ${d.ai_ready ? 'available' : 'unavailable'}"><span class="badge-dot"></span>AI</span><span class="mode-badge ${d.wechat_running ? 'available' : 'unavailable'}"><span class="badge-dot"></span>微信</span><span class="mode-badge ${d.screen_on ? 'available' : 'unavailable'}"><span class="badge-dot"></span>屏幕</span></div></div>`;
|
||
}).join('')}</div></div>` : ''}
|
||
<div class="panel glass"><div class="panel-head"><div><h2>健康事件</h2></div></div><div class="card"><table class="list-table"><thead><tr><th>时间</th><th>设备</th><th>级别</th><th>事件</th></tr></thead><tbody>${hbEvents.length ? hbEvents.slice(0, 30).map(ev => { const lvlColor = ev.level === 'CRITICAL' ? 'var(--red)' : ev.level === 'WARNING' ? 'var(--orange)' : 'var(--sub)'; return `<tr><td>${ev.time ? new Date(ev.time).toLocaleTimeString('zh-CN',{hour12:false}) : '-'}</td><td>${esc((ev.device_id || '').substring(0,12))}</td><td style="color:${lvlColor};font-weight:600">${esc(ev.level || '-')}</td><td>${esc(ev.message || '-')}</td></tr>`; }).join('') : '<tr><td colspan="4" class="empty">暂无事件 — 设备运行正常</td></tr>'}</tbody></table></div></div>
|
||
<div class="panel glass"><div class="panel-head"><div><h2>AI 任务队列</h2><p class="sub">向设备推送 AI 任务,通过心跳周期自动下发</p></div></div><div class="grid col2"><div class="card"><h3>推送任务</h3><div style="margin-top:10px"><select id="hb-device-select" style="width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);background:var(--panel);font-size:13px;margin-bottom:8px">${(state.devices || []).map(d => `<option value="${d.device_id}">${esc(d.device_id)} (${d.status || 'offline'})</option>`).join('')}</select><input id="hb-task-input" placeholder="输入 AI 任务指令" style="width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);font-size:13px;margin-bottom:8px"><button class="btn primary" onclick="pushHeartbeatTask()" style="width:100%">推送到任务队列</button></div><div id="hb-task-result" style="margin-top:10px;font-size:12px;color:var(--sub)"></div></div><div class="card"><h3>队列说明</h3><div style="font-size:12.5px;color:var(--sub);line-height:1.8;margin-top:8px"><div>1. 任务通过 API 推入设备队列</div><div>2. 每次心跳周期(10s)检查队列</div><div>3. 自动批量下发(每次最多 3 个)</div><div>4. 设备 Agent 收到后立即执行</div><div>5. 执行结果通过 WS 事件回传</div></div></div></div></div>`;
|
||
} else if (tab === 'antiban') {
|
||
const ab = state.antibanStatus || {};
|
||
const hours = ab.operation_hours || {};
|
||
const limits = (ab.platform_limits || {}).wechat || {};
|
||
const device = ab.device || {};
|
||
const red = device.redlight || {};
|
||
const sentinel = device.sentinel || {};
|
||
const warnings = device.guard_warnings || [];
|
||
const limitRows = Object.entries(limits).map(([action, cfg]) => `
|
||
<tr>
|
||
<td><code>${esc(action)}</code></td>
|
||
<td>${(cfg.interval || []).join(' - ')}s</td>
|
||
<td>${cfg.daily_max ?? '-'}</td>
|
||
<td>${cfg.new_daily_max ?? '-'}</td>
|
||
</tr>`).join('');
|
||
const redTone = red.level === 'red' ? 'var(--red)' : red.level === 'yellow' ? 'var(--orange)' : 'var(--green)';
|
||
const rt = device.runtime || {};
|
||
const st = rt.silent_throttle || {};
|
||
const lp = rt.long_pause || {};
|
||
const hr = rt.last_highrisk;
|
||
const stTone = st.active ? 'var(--red)' : 'var(--green)';
|
||
const lpTone = lp.active ? 'var(--orange)' : 'var(--green)';
|
||
const fmtMin = s => s >= 60 ? `${Math.round(s/60)}min` : `${s}s`;
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>🛡️ 防封风控</h2><p class="sub">账号暖机 · 限流节律 · Root/Frida 红灯 · AB-02 静默限流熔断</p></div>
|
||
<div class="btns"><button class="btn soft" onclick="refreshAntiban()">刷新防封状态</button></div>
|
||
</div>
|
||
<div class="stats" style="grid-template-columns:repeat(4,1fr);margin-bottom:8px">
|
||
${statCard('操作时段', `${(hours.range || [7,23]).join(':00-')}:00`, hours.in_window ? '当前允许操作' : '当前应暂停')}
|
||
${statCard('红灯等级', red.level || 'unknown', red.reason || '等待设备上报')}
|
||
${statCard('RiskSentinel', sentinel.level_name || sentinel.level || 'unknown', `失败 ${sentinel.consecutive_failures ?? '-'}`)}
|
||
${statCard('长暂停', ab.long_pause?.pause_seconds || '600-1800', `每 ${ab.long_pause?.every_actions || '8-12'} 动作`)}
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>AB-02/03 实时熔断(本设备)</h2><p class="sub">rate_limiter 运行态 · /antiban/status?device_id 真源</p></div></div>
|
||
<div class="stats" style="grid-template-columns:repeat(3,1fr)">
|
||
${statCard('AB-02 静默限流熔断', st.active ? `熔断中 ${fmtMin(st.until_in_s||0)}` : '正常', `<span style="color:${stTone}">累计触发 ${st.hits ?? 0} 次</span>`)}
|
||
${statCard('AB-03 长暂停', lp.active ? `暂停中 ${fmtMin(lp.until_in_s||0)}` : '正常', `<span style="color:${lpTone}">动作节拍 ${lp.action_seq ?? 0}${lp.next_pause_at ? '/'+lp.next_pause_at : ''}</span>`)}
|
||
${statCard('最近高危动作', hr ? hr.action : '无', hr ? `${hr.ago_s}s 前` : '窗口内无高危')}
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>微信动作限流</h2><p class="sub">服务端 _anti_ban_guard 真源,所有写操作必须经过该守卫</p></div></div>
|
||
<div class="card">
|
||
<table class="list-table">
|
||
<thead><tr><th>动作</th><th>随机间隔</th><th>成熟号/日</th><th>新号/日</th></tr></thead>
|
||
<tbody>${limitRows || '<tr><td colspan="4" class="empty">暂无限流配置</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="grid col2">
|
||
<div class="card">
|
||
<h3 style="color:${redTone}">设备红灯 / Guard</h3>
|
||
<p>设备:<code>${esc(device.device_id || state.currentDevice || '-')}</code> · 在线:${device.online ? '✅' : '❌'}</p>
|
||
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
|
||
${(warnings || []).length ? warnings.map(w => `<span class="pill" style="color:var(--orange);background:rgba(245,158,11,.08)">${esc(w)}</span>`).join('') : '<span class="pill" style="color:var(--green)">暂无警告</span>'}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>高危动作互斥</h3>
|
||
<p>以下动作不得同窗口并发,触发风控后需静置 24-48h。</p>
|
||
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
|
||
${(ab.high_risk_exclusive || []).map(x => `<span class="pill">${esc(x)}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>下一步防封 WBS</h2><p class="sub">共享基线:开发文档/2、架构/05-规范/防封共享策略与执行基线.md</p></div></div>
|
||
<div class="grid col3">
|
||
${card('AB-02 静默限流 ✅','success 但无 message_id / svr_id → 设备级熔断,写类冷却 30-60min 逐次退避')}
|
||
${card('AB-03 长暂停 ✅','每 8-12 个动作插入 10-30 分钟长暂停 + 高危动作互斥')}
|
||
${card('AB-04 量产红灯','prod 下 Root/Frida/ADB 暴露标红,dev 标黄')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
return header + content;
|
||
}
|
||
|
||
/* ═══ 4.5 Gateway Panel (外部对接 + Fleet + Journey) ═══ */
|
||
|
||
function renderGatewayPanel(){
|
||
const tab = state.gatewayTab;
|
||
const info = state.gatewayInfo || {};
|
||
const fleet = state.fleetStatus || {};
|
||
const journeys = state.journeys || {};
|
||
|
||
const header = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>🔗 对接网关</h2><p class="sub">REST API · OpenAI Compatible · MCP Protocol · 多设备管理 · AI 旅程</p></div>
|
||
<div class="btns"><button class="btn soft" onclick="refreshGateway()">刷新</button></div>
|
||
</div>
|
||
<div class="mode-tabs">
|
||
<div class="mode-btn ${tab==='overview'?'active':''}" onclick="state.gatewayTab='overview';renderCurrentView()">🌐 概览</div>
|
||
<div class="mode-btn ${tab==='fleet'?'active':''}" onclick="state.gatewayTab='fleet';renderCurrentView()">📱 多设备管理</div>
|
||
<div class="mode-btn ${tab==='journey'?'active':''}" onclick="state.gatewayTab='journey';renderCurrentView()">🛤️ AI 旅程</div>
|
||
<div class="mode-btn ${tab==='mcp'?'active':''}" onclick="state.gatewayTab='mcp';renderCurrentView()">🧩 MCP Tools</div>
|
||
<div class="mode-btn ${tab==='try'?'active':''}" onclick="state.gatewayTab='try';renderCurrentView()">⚡ 测试</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
let content = '';
|
||
|
||
if (tab === 'overview') {
|
||
const protocols = info.protocols || {};
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>三种对接方式</h2><p class="sub">外部程序通过以下任一方式接入工作手机 SDK,实现多台手机统一管控</p></div></div>
|
||
<div class="grid col3">
|
||
<div class="card" style="border-top:3px solid var(--blue)">
|
||
<h3 style="color:var(--blue)">🌐 REST API</h3>
|
||
<p style="margin:8px 0">192+ 端点,覆盖微信全功能、设备控制、AI 任务。</p>
|
||
<div class="path">Base: /api/v3</div>
|
||
<div style="margin-top:10px"><a class="btn soft" href="/docs" target="_blank" style="text-decoration:none">📄 Swagger 文档</a></div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--green)">
|
||
<h3 style="color:var(--green)">🤖 OpenAI Compatible</h3>
|
||
<p style="margin:8px 0">像调用 GPT 一样控制手机,支持所有 OpenAI SDK。</p>
|
||
<div class="path">Base: /api/v3/gateway/v1</div>
|
||
<div style="margin-top:10px;font-size:11px;color:var(--sub);line-height:1.8">
|
||
<code style="background:rgba(22,163,74,.08);padding:2px 6px;border-radius:4px">POST</code> /chat/completions<br>
|
||
<code style="background:rgba(22,163,74,.08);padding:2px 6px;border-radius:4px">GET</code> /models
|
||
</div>
|
||
</div>
|
||
<div class="card" style="border-top:3px solid var(--purple)">
|
||
<h3 style="color:var(--purple)">🧩 MCP Protocol</h3>
|
||
<p style="margin:8px 0">8 个工具,可直接被 Claude/Cursor 等 AI Agent 调用。</p>
|
||
<div class="path">Base: /api/v3/gateway/mcp</div>
|
||
<div style="margin-top:10px;font-size:11px;color:var(--sub);line-height:1.8">
|
||
<code style="background:rgba(124,58,237,.08);padding:2px 6px;border-radius:4px">GET</code> /tools<br>
|
||
<code style="background:rgba(124,58,237,.08);padding:2px 6px;border-radius:4px">POST</code> /call
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>OpenAI SDK 接入示例</h2><p class="sub">Python / cURL 一行代码即可控制手机</p></div></div>
|
||
<div class="grid col2">
|
||
<div class="card">
|
||
<h3>Python 示例</h3>
|
||
<pre style="background:#0f172a;color:#e2e8f0;padding:14px;border-radius:12px;font-size:12px;line-height:1.7;overflow-x:auto;margin-top:8px">from openai import OpenAI
|
||
|
||
client = OpenAI(
|
||
base_url="http://服务器:8899/api/v3/gateway/v1",
|
||
api_key="any"
|
||
)
|
||
|
||
resp = client.chat.completions.create(
|
||
model="workphone-agent",
|
||
messages=[{
|
||
"role": "user",
|
||
"content": "打开微信,给张三发消息:你好"
|
||
}]
|
||
)
|
||
print(resp.choices[0].message.content)</pre>
|
||
</div>
|
||
<div class="card">
|
||
<h3>cURL 示例</h3>
|
||
<pre style="background:#0f172a;color:#e2e8f0;padding:14px;border-radius:12px;font-size:12px;line-height:1.7;overflow-x:auto;margin-top:8px"># 发送 AI 指令
|
||
curl -X POST \\
|
||
http://服务器:8899/api/v3/gateway/v1/chat/completions \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"model": "workphone-agent",
|
||
"messages": [{
|
||
"role": "user",
|
||
"content": "检查微信未读消息"
|
||
}]
|
||
}'
|
||
|
||
# MCP 工具调用
|
||
curl -X POST \\
|
||
http://服务器:8899/api/v3/gateway/mcp/call \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"name": "send_message",
|
||
"arguments": {
|
||
"to": "张三",
|
||
"content": "你好"
|
||
}
|
||
}'</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>管理入口</h2><p class="sub">多设备批量控制、状态监控和旅程追踪</p></div></div>
|
||
<div class="grid col3">
|
||
${card('📱 多设备管理', '设备分组 · 批量广播 · 全局状态<br><code>GET /gateway/fleet/status</code>', '<button class="btn soft" onclick="state.gatewayTab=\'fleet\';renderCurrentView()">进入</button>')}
|
||
${card('🛤️ AI 旅程', '每台手机的操作历史与 AI 决策记录<br><code>GET /gateway/journey</code>', '<button class="btn soft" onclick="state.gatewayTab=\'journey\';renderCurrentView()">进入</button>')}
|
||
${card('⚡ 在线测试', '直接测试三种对接方式<br>发送请求查看结果', '<button class="btn soft" onclick="state.gatewayTab=\'try\';renderCurrentView()">进入</button>')}
|
||
</div>
|
||
</div>`;
|
||
|
||
} else if (tab === 'fleet') {
|
||
const devices = fleet.devices || [];
|
||
const groups = fleet.groups || {};
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>多设备管理</h2><p class="sub">${fleet.total || 0} 台设备 · ${fleet.healthy || 0} 健康 · ${fleet.offline || 0} 离线</p></div>
|
||
<div class="btns">
|
||
<button class="btn primary" onclick="fleetBroadcast()">📢 广播命令</button>
|
||
<button class="btn soft" onclick="refreshFleet()">刷新</button>
|
||
</div>
|
||
</div>
|
||
<div class="stats" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px">
|
||
${statCard('设备总数', fleet.total || 0, '已注册设备')}
|
||
${statCard('健康设备', fleet.healthy || 0, '可控制')}
|
||
${statCard('离线', fleet.offline || 0, '不可达')}
|
||
${statCard('分组', Object.keys(groups).length, '设备分组')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>设备列表</h2><p class="sub">所有已连接设备的实时状态</p></div></div>
|
||
<div class="card">
|
||
<table class="list-table">
|
||
<thead><tr><th>设备ID</th><th>状态</th><th>通道</th><th>型号</th><th>电量</th><th>网络</th><th>当前APP</th></tr></thead>
|
||
<tbody>
|
||
${devices.length ? devices.map(d => `
|
||
<tr>
|
||
<td style="font-family:monospace;font-size:11px">${esc(d.device_id?.substring(0,16))}${(d.device_id||'').length>16?'…':''}</td>
|
||
<td>${d.status==='online'?'<span style="color:var(--green);font-weight:600">● 在线</span>':d.status==='adb'?'<span style="color:var(--blue);font-weight:600">● ADB</span>':'<span style="color:var(--red)">○ 离线</span>'}</td>
|
||
<td>${esc(d.channel||'-')}</td>
|
||
<td>${esc(d.model||'-')}</td>
|
||
<td>${d.battery>=0?d.battery+'%':'-'}</td>
|
||
<td>${esc(d.network||'-')}</td>
|
||
<td>${esc(d.current_app||'-')}</td>
|
||
</tr>
|
||
`).join('') : '<tr><td colspan="7" class="empty">暂无设备</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
${Object.keys(groups).length ? `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>设备分组</h2></div></div>
|
||
<div class="grid col3">
|
||
${Object.entries(groups).map(([name, ids]) => card(name, `${ids.length} 台设备<br>${ids.slice(0,3).map(id => '<code>' + esc(id.substring(0,12)) + '</code>').join(', ')}${ids.length>3?'..':''}`)).join('')}
|
||
</div>
|
||
</div>` : ''}`;
|
||
|
||
} else if (tab === 'journey') {
|
||
const allJourneys = journeys.journeys || {};
|
||
const deviceIds = journeys.devices || [];
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head">
|
||
<div><h2>AI 旅程追踪</h2><p class="sub">每台手机的完整操作历史 · AI 决策链 · ${journeys.total_entries || 0} 条记录</p></div>
|
||
<div class="btns"><button class="btn soft" onclick="refreshJourney()">刷新</button></div>
|
||
</div>
|
||
<div class="stats" style="grid-template-columns:repeat(3,1fr);margin-bottom:12px">
|
||
${statCard('记录总数', journeys.total_entries || 0, '操作轨迹')}
|
||
${statCard('活跃设备', deviceIds.length || 0, '有旅程记录')}
|
||
${statCard('追踪状态', journeys.total_entries > 0 ? '运行中' : '等待', '实时记录')}
|
||
</div>
|
||
</div>
|
||
|
||
${deviceIds.length ? deviceIds.map(did => {
|
||
const entries = allJourneys[did] || [];
|
||
return `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>📱 ${esc(did.substring(0,16))}${did.length>16?'…':''}</h2><p class="sub">${entries.length} 条旅程记录</p></div></div>
|
||
<div class="card">
|
||
<table class="list-table">
|
||
<thead><tr><th>时间</th><th>操作</th><th>来源</th><th>结果</th></tr></thead>
|
||
<tbody>
|
||
${entries.slice(0,20).map(e => `
|
||
<tr>
|
||
<td style="font-size:11px;white-space:nowrap">${esc(e.time_str||'-')}</td>
|
||
<td style="font-weight:600">${esc(e.action||'-')}</td>
|
||
<td><span class="pill">${esc(e.source||'api')}</span></td>
|
||
<td style="font-size:11.5px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((e.result||'').substring(0,80))}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}).join('') : `
|
||
<div class="panel glass">
|
||
<div class="card"><div class="empty" style="padding:40px">暂无旅程记录 · 设备开始操作后自动追踪</div></div>
|
||
</div>`}`;
|
||
|
||
} else if (tab === 'mcp') {
|
||
const tools = (info.protocols?.mcp?.tools || []);
|
||
content = `
|
||
<div class="panel glass">
|
||
<div class="panel-head"><div><h2>MCP Tools 清单</h2><p class="sub">MCP Protocol 兼容的工具列表,可被 AI Agent 直接调用</p></div></div>
|
||
</div>
|
||
${['send_message','take_screenshot','open_app','get_device_status','list_devices','execute_task','add_friend','batch_send'].map((name,i) => {
|
||
const descriptions = {
|
||
send_message: '通过微信发送消息给指定联系人',
|
||
take_screenshot: '对手机截屏,返回当前屏幕画面',
|
||
open_app: '打开手机上的APP(微信/抖音/小红书/闲鱼)',
|
||
get_device_status: '获取设备实时状态(电量、网络、APP等)',
|
||
list_devices: '列出所有已连接设备及状态',
|
||
execute_task: '自然语言任务 → AI Agent 自动执行',
|
||
add_friend: '在微信中添加好友',
|
||
batch_send: '批量发送消息给多个联系人(含防封间隔)',
|
||
};
|
||
const icons = {
|
||
send_message:'💬', take_screenshot:'📸', open_app:'📱',
|
||
get_device_status:'📊', list_devices:'📋', execute_task:'🤖',
|
||
add_friend:'🤝', batch_send:'📨'
|
||
};
|
||
return `
|
||
<div class="panel glass" style="padding:18px 22px">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div style="font-size:28px">${icons[name]||'⚡'}</div>
|
||
<div style="flex:1">
|
||
<div style="font-weight:700;font-size:14px">${name}</div>
|
||
<div style="font-size:12px;color:var(--sub);margin-top:2px">${descriptions[name]||''}</div>
|
||
</div>
|
||
<button class="btn soft" onclick="testMcpTool('${name}')">测试</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('')}`;
|
||
|
||
} else if (tab === 'try') {
|
||
content = `
|
||
<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>OpenAI Chat 测试</h3>
|
||
<div style="margin-top:10px">
|
||
<input id="gw-chat-input" placeholder="输入自然语言指令…" style="width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);font-size:13px;margin-bottom:8px">
|
||
<button class="btn primary" onclick="testOpenAIChat()" style="width:100%">发送到 OpenAI API</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>MCP Tool 测试</h3>
|
||
<div style="margin-top:10px">
|
||
<select id="gw-mcp-tool" style="width:100%;padding:10px;border-radius:10px;border:1px solid var(--border);font-size:13px;margin-bottom:8px">
|
||
<option value="list_devices">list_devices</option>
|
||
<option value="get_device_status">get_device_status</option>
|
||
<option value="take_screenshot">take_screenshot</option>
|
||
<option value="open_app">open_app</option>
|
||
<option value="send_message">send_message</option>
|
||
</select>
|
||
<button class="btn primary" onclick="testMcpToolDirect()" style="width:100%">调用 MCP Tool</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="gw-test-result" style="margin-top:14px;background:rgba(15,23,42,.03);border-radius:var(--radius-sm);padding:16px;min-height:100px;font-size:12.5px;line-height:1.7;white-space:pre-wrap;color:var(--sub);font-family:ui-monospace,monospace">
|
||
点击上方按钮发送测试请求
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
return header + content;
|
||
}
|
||
|
||
async function refreshGateway(){
|
||
try {
|
||
const [infoRes, fleetRes, journeyRes] = await Promise.all([
|
||
apiGet('/api/v3/gateway/info'),
|
||
apiGet('/api/v3/gateway/fleet/status').catch(()=>({data:{}})),
|
||
apiGet('/api/v3/gateway/journey').catch(()=>({data:{}})),
|
||
]);
|
||
state.gatewayInfo = infoRes;
|
||
state.fleetStatus = fleetRes.data || {};
|
||
state.journeys = journeyRes.data || {};
|
||
if (state.current === 'gateway') renderCurrentView();
|
||
} catch(e) {
|
||
log('网关数据加载失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
async function refreshFleet(){ try { const r = await apiGet('/api/v3/gateway/fleet/status'); state.fleetStatus = r.data || {}; if(state.current==='gateway')renderCurrentView(); } catch(e){} }
|
||
async function refreshJourney(){ try { const r = await apiGet('/api/v3/gateway/journey'); state.journeys = r.data || {}; if(state.current==='gateway')renderCurrentView(); } catch(e){} }
|
||
|
||
async function testOpenAIChat(){
|
||
const input = document.getElementById('gw-chat-input');
|
||
const result = document.getElementById('gw-test-result');
|
||
const msg = input?.value?.trim();
|
||
if (!msg) { result.textContent = '请输入指令'; return; }
|
||
result.textContent = '请求中...';
|
||
try {
|
||
const res = await apiPost('/api/v3/gateway/v1/chat/completions', {
|
||
model: 'workphone-agent',
|
||
messages: [{role:'user', content: msg}]
|
||
});
|
||
result.textContent = JSON.stringify(res, null, 2);
|
||
} catch(e) { result.textContent = '错误: ' + e.message; }
|
||
}
|
||
|
||
async function testMcpTool(name){
|
||
state.gatewayTab = 'try';
|
||
renderCurrentView();
|
||
setTimeout(() => testMcpToolWithName(name), 100);
|
||
}
|
||
|
||
async function testMcpToolWithName(name){
|
||
const result = document.getElementById('gw-test-result');
|
||
if (result) result.textContent = `调用 MCP 工具: ${name} ...`;
|
||
try {
|
||
const res = await apiPost('/api/v3/gateway/mcp/call', {name, arguments:{}});
|
||
if (result) result.textContent = JSON.stringify(res, null, 2);
|
||
} catch(e) {
|
||
if (result) result.textContent = '错误: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function testMcpToolDirect(){
|
||
const tool = document.getElementById('gw-mcp-tool')?.value || 'list_devices';
|
||
await testMcpToolWithName(tool);
|
||
}
|
||
|
||
async function fleetBroadcast(){
|
||
const cmd = prompt('输入广播命令(发送到所有设备):', '检查微信状态');
|
||
if (!cmd) return;
|
||
try {
|
||
const res = await apiPost('/api/v3/gateway/fleet/broadcast', {command: cmd});
|
||
const data = res.data || {};
|
||
log(`广播完成: ${data.success||0}/${data.total||0} 成功`, 'info');
|
||
refreshFleet();
|
||
} catch(e) { log('广播失败: ' + e.message, 'error'); }
|
||
}
|
||
|
||
/* ═══ 5. Docs Panel (Documents + Architecture + Project) ═══ */
|
||
|
||
function renderDocsPanel(){
|
||
const allDocs = state.docs;
|
||
const assets = state.assets || [];
|
||
const projectDocs = allDocs.filter(d=>['project'].includes(d.category));
|
||
const archDocs = allDocs.filter(d=>['architecture','api'].includes(d.category));
|
||
const tab = state.docsTab;
|
||
const cats = [...new Set(allDocs.map(d=>d.section))];
|
||
const filter = state.docFilter || '';
|
||
const filteredDocs = filter ? allDocs.filter(d=>d.section===filter) : allDocs;
|
||
const selected = state.selectedDoc || filteredDocs[0]?.id;
|
||
state.selectedDoc = selected;
|
||
if (tab==='manual' && 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:16px">
|
||
<div class="mode-btn ${tab==='manual'?'active':''}" onclick="state.docsTab='manual';renderCurrentView()">📖 文档 (${allDocs.length})</div>
|
||
<div class="mode-btn ${tab==='arch'?'active':''}" onclick="state.docsTab='arch';renderCurrentView()">🧭 架构图 (${assets.length})</div>
|
||
<div class="mode-btn ${tab==='project'?'active':''}" onclick="state.docsTab='project';renderCurrentView()">📊 项目</div>
|
||
</div>
|
||
${tab==='manual' ? `
|
||
<div class="mode-tabs" style="margin-bottom:14px">
|
||
<div class="mode-btn ${!filter?'active':''}" onclick="filterDocs('')">全部</div>
|
||
${cats.map(c=>`<div class="mode-btn ${filter===c?'active':''}" onclick="filterDocs('${c}')">${c}</div>`).join('')}
|
||
</div>
|
||
<div class="doc-layout">
|
||
<div class="doc-list">
|
||
${filteredDocs.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>
|
||
` : tab==='arch' ? `
|
||
<div class="asset-grid">
|
||
${assets.map(a=>`<div class="asset-card"><h3>${a.title}</h3><p style="font-size:12px;color:var(--sub);margin-bottom:10px">${a.summary}</p><img src="${a.url}" alt="${a.title}"></div>`).join('')}
|
||
</div>
|
||
${archDocs.length ? `<div class="grid col3" style="margin-top:16px">${archDocs.map(d=>`<div class="card" onclick="state.docsTab='manual';state.selectedDoc='${d.id}';renderCurrentView()" style="cursor:pointer"><h3>${d.title}</h3><p>${d.summary}</p></div>`).join('')}</div>` : ''}
|
||
` : `
|
||
<div class="grid col3">
|
||
${projectDocs.map(d=>`<div class="card" onclick="state.docsTab='manual';state.selectedDoc='${d.id}';renderCurrentView()" style="cursor:pointer"><h3>${d.title}</h3><p>${d.summary}</p><div class="path">${d.folder}/${d.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>')}
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ═══ Navigation & Doc Helpers ═══ */
|
||
|
||
function openDocFromHome(docId){
|
||
state.current='docs'; state.docsTab='manual'; state.selectedDoc=docId;
|
||
renderSidebar(); renderCurrentView();
|
||
}
|
||
|
||
function filterDocs(section){
|
||
state.docFilter=section; state.selectedDoc=null;
|
||
const v=document.getElementById('doc-view');if(v)delete v.dataset.loaded;
|
||
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>`;
|
||
}
|
||
}
|
||
|
||
async function openAgentDoc(docId){ openDoc(docId); }
|
||
|
||
/* ═══ Hook Data Functions ═══ */
|
||
|
||
async function fetchHookData(){
|
||
const did = state.currentDevice;
|
||
if (!did) { alert('请先选择设备'); return; }
|
||
state.hookDataLoading = true;
|
||
renderCurrentView();
|
||
try {
|
||
const r = await authenticatedFetch(`/api/v3/hook/data/${did}?modules=profile,contacts,groups,labels,messages,device_info&contact_limit=200&message_limit=50&contact_offset=0&message_offset=0`);
|
||
const j = await r.json();
|
||
if (j.success && j.data) {
|
||
state.hookData = j.data;
|
||
log(`Hook 数据预览已加载: ${Object.keys(j.data).length} 个模块(完整数据走分页接口)`, 'info');
|
||
} else {
|
||
state.hookData = {};
|
||
log('Hook 数据加载失败: ' + (j.error || '未知错误'), 'error');
|
||
}
|
||
} catch(e) {
|
||
log('Hook 数据请求异常: ' + e.message, 'error');
|
||
state.hookData = {};
|
||
}
|
||
state.hookDataLoading = false;
|
||
renderCurrentView();
|
||
}
|
||
|
||
function filterHookTable(type){
|
||
const input = document.getElementById(`hook-${type === 'contacts' ? 'contact' : 'group'}-search`);
|
||
if (!input) return;
|
||
const q = input.value.toLowerCase();
|
||
const rows = document.querySelectorAll(`.hook-${type === 'contacts' ? 'contact' : 'group'}-row`);
|
||
rows.forEach(row => {
|
||
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
/* ═══ AI Brain Functions ═══ */
|
||
|
||
async function loadBrainDashboard(){
|
||
try {
|
||
const [regRes, dashRes] = await Promise.all([
|
||
apiGet('/api/v3/ai/brain/skill-registry'),
|
||
apiGet('/api/v3/ai/brain/dashboard'),
|
||
]);
|
||
state.skillRegistry = regRes.data || {};
|
||
state.brainDashboard = dashRes.data || {};
|
||
renderCurrentView();
|
||
log('AI Brain 仪表盘已刷新', 'info');
|
||
} catch(e) {
|
||
log('AI Brain 数据加载失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function fillBrainPreset(script, action, paramsStr){
|
||
const se = document.getElementById('brain-script');
|
||
const ae = document.getElementById('brain-action');
|
||
const pe = document.getElementById('brain-params');
|
||
if (se) se.value = script;
|
||
if (ae) ae.value = action;
|
||
if (pe) pe.value = paramsStr || '{}';
|
||
}
|
||
|
||
function fillBrainScript(script, action){
|
||
const se = document.getElementById('brain-script');
|
||
const ae = document.getElementById('brain-action');
|
||
if (se) se.value = script;
|
||
if (ae) ae.value = action;
|
||
const paramHints = {
|
||
'send_message': '{"to_id": "联系人名", "content": "消息内容"}',
|
||
'get_messages': '{"limit": 3000, "offset": 0}',
|
||
'get_contacts': '{"limit": 10000, "offset": 0}',
|
||
'add_friend': '{"user_id": "微信号", "message": "你好"}',
|
||
'accept_friend': '{}',
|
||
'create_group': '{"group_name": "群名", "member_ids": ["id1","id2"]}',
|
||
'post_moments': '{"content": "朋友圈内容"}',
|
||
'screenshot': '{}',
|
||
'app_start': '{"package": "com.tencent.mm"}',
|
||
'get_profile': '{}',
|
||
};
|
||
const pe = document.getElementById('brain-params');
|
||
if (pe && paramHints[action]) pe.value = paramHints[action];
|
||
else if (pe) pe.value = '{}';
|
||
}
|
||
|
||
async function brainExecuteScript(){
|
||
const el = document.getElementById('aibrain-result');
|
||
const did = state.currentDevice;
|
||
const script = document.getElementById('brain-script')?.value?.trim();
|
||
const action = document.getElementById('brain-action')?.value?.trim();
|
||
const channel = document.getElementById('brain-channel')?.value || 'auto';
|
||
let params = {};
|
||
try { params = JSON.parse(document.getElementById('brain-params')?.value || '{}'); } catch(e) {
|
||
el.innerHTML = '<span style="color:#fca5a5">// Params JSON 格式错误: ' + esc(e.message) + '</span>';
|
||
return;
|
||
}
|
||
if (!did) { el.innerHTML = '<span style="color:#fca5a5">// 请先选择设备</span>'; return; }
|
||
if (!script || !action) { el.innerHTML = '<span style="color:#fca5a5">// 请填写 script 和 action</span>'; return; }
|
||
|
||
el.innerHTML = `<span style="color:#7dd3fc">// 执行中: ${esc(script)}.${esc(action)} → 设备 ${esc(did)}</span>\n<span style="color:#94a3b8">// 通道: ${channel} | 等待设备响应...</span>`;
|
||
try {
|
||
const res = await apiPost('/api/v3/ai/brain/execute-script', {device_id: did, script, action, params, channel});
|
||
const ts = new Date().toLocaleTimeString();
|
||
el.innerHTML = `<span style="color:#86efac">// ✅ [${ts}] 执行成功: ${esc(script)}.${esc(action)}</span>\n` + JSON.stringify(res.data, null, 2);
|
||
log(`脚本执行成功: ${script}.${action} → ${did}`, 'info');
|
||
} catch(e) {
|
||
el.innerHTML = `<span style="color:#fca5a5">// ❌ 执行失败: ${esc(e.message)}</span>`;
|
||
log(`脚本执行失败: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function brainBatchExecute(){
|
||
const el = document.getElementById('aibrain-result');
|
||
const script = document.getElementById('brain-script')?.value?.trim();
|
||
const action = document.getElementById('brain-action')?.value?.trim();
|
||
let params = {};
|
||
try { params = JSON.parse(document.getElementById('brain-params')?.value || '{}'); } catch(e) {
|
||
el.innerHTML = '<span style="color:#fca5a5">// Params JSON 格式错误</span>';
|
||
return;
|
||
}
|
||
if (!script || !action) { el.innerHTML = '<span style="color:#fca5a5">// 请填写 script 和 action</span>'; return; }
|
||
|
||
const onlineDevices = (state.devices||[]).filter(d=>d.status==='online').map(d=>d.device_id);
|
||
if (!onlineDevices.length) { el.innerHTML = '<span style="color:#fca5a5">// 没有在线设备</span>'; return; }
|
||
|
||
el.innerHTML = `<span style="color:#7dd3fc">// 批量执行: ${esc(script)}.${esc(action)} → ${onlineDevices.length} 台设备</span>\n<span style="color:#94a3b8">// 设备: ${onlineDevices.join(', ')}</span>`;
|
||
try {
|
||
const res = await apiPost('/api/v3/ai/brain/batch-execute', {device_ids: onlineDevices, script, action, params});
|
||
const d = res.data || {};
|
||
el.innerHTML = `<span style="color:#86efac">// ✅ 批量执行完成: 成功 ${d.success||0}/${d.total||0}</span>\n` + JSON.stringify(d.results, null, 2);
|
||
} catch(e) {
|
||
el.innerHTML = `<span style="color:#fca5a5">// ❌ 批量执行失败: ${esc(e.message)}</span>`;
|
||
}
|
||
}
|
||
|
||
async function aiBrainLoadStatus(){
|
||
const el = document.getElementById('aibrain-result');
|
||
const did = state.currentDevice;
|
||
if (!did) { el.innerHTML = '<span style="color:#fca5a5">// 请先选择设备</span>'; return; }
|
||
el.innerHTML = '<span style="color:#7dd3fc">// 加载 AI Brain 状态...</span>';
|
||
try {
|
||
const res = await apiGet(`/api/v3/devices/${did}/ai/status`);
|
||
el.innerHTML = `<span style="color:#86efac">// ✅ AI Brain 状态 (${esc(did)})</span>\n` + JSON.stringify(res.data, null, 2);
|
||
} catch(e) { el.innerHTML = '<span style="color:#fca5a5">// 错误: ' + esc(e.message||e) + '</span>'; }
|
||
}
|
||
|
||
async function aiBrainPushTask(){
|
||
const did = state.currentDevice;
|
||
if (!did) return;
|
||
const instruction = prompt('输入 AI 任务指令:', '检查微信未读消息并回复');
|
||
if (!instruction) return;
|
||
const el = document.getElementById('aibrain-result');
|
||
el.innerHTML = '<span style="color:#7dd3fc">// 推送 AI 任务...</span>';
|
||
try {
|
||
const res = await apiPost(`/api/v3/devices/${did}/ai/task`, {instruction, priority: 5});
|
||
el.innerHTML = `<span style="color:#86efac">// ✅ 任务已推送到 ${esc(did)}</span>\n` + JSON.stringify(res.data, null, 2);
|
||
} catch(e) { el.innerHTML = '<span style="color:#fca5a5">// 推送失败: ' + esc(e.message||e) + '</span>'; }
|
||
}
|
||
|
||
async function aiBrainPushOrder(){
|
||
const did = state.currentDevice;
|
||
if (!did) return;
|
||
const order = prompt('输入常驻指令(离线时自动执行):', '每隔1小时检查微信未读消息');
|
||
if (!order) return;
|
||
const el = document.getElementById('aibrain-result');
|
||
el.innerHTML = '<span style="color:#7dd3fc">// 推送常驻指令...</span>';
|
||
try {
|
||
const res = await apiPost(`/api/v3/devices/${did}/ai/standing-order`, {order});
|
||
el.innerHTML = `<span style="color:#86efac">// ✅ 常驻指令已推送</span>\n` + JSON.stringify(res.data, null, 2);
|
||
} catch(e) { el.innerHTML = '<span style="color:#fca5a5">// 推送失败: ' + esc(e.message||e) + '</span>'; }
|
||
}
|
||
|
||
async function aiBrainExecute(){
|
||
const did = state.currentDevice;
|
||
if (!did) return;
|
||
const task = prompt('输入自然语言任务:', '打开微信,给张三发一条消息:你好');
|
||
if (!task) return;
|
||
const el = document.getElementById('aibrain-result');
|
||
el.innerHTML = '<span style="color:#7dd3fc">// 执行中(等待设备响应)...</span>';
|
||
try {
|
||
const res = await apiPost(`/api/v3/devices/${did}/ai/execute`, {task, timeout: 60});
|
||
el.innerHTML = `<span style="color:#86efac">// ✅ 执行完成</span>\n` + JSON.stringify(res.data, null, 2);
|
||
} catch(e) { el.innerHTML = '<span style="color:#fca5a5">// 执行失败: ' + esc(e.message||e) + '</span>'; }
|
||
}
|
||
|
||
/* ═══ Heartbeat Task Push ═══ */
|
||
|
||
async function pushHeartbeatTask(){
|
||
const deviceId = document.getElementById('hb-device-select')?.value;
|
||
const instruction = document.getElementById('hb-task-input')?.value?.trim();
|
||
const result = document.getElementById('hb-task-result');
|
||
if (!deviceId || !instruction) {
|
||
if (result) result.textContent = '请选择设备并输入任务指令';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await apiPost(`/api/v3/devices/${deviceId}/ai/queue-task`, {instruction, priority: 5});
|
||
if (result) result.innerHTML = '<span style="color:var(--green)">✓ 任务已入队</span> · 队列: ' + (res.data?.pending_count || 0) + ' 个';
|
||
document.getElementById('hb-task-input').value = '';
|
||
} catch(e) {
|
||
if (result) result.innerHTML = '<span style="color:var(--red)">推送失败: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
/* ═══ Bind QR ═══ */
|
||
|
||
async function openBindQRCode(){
|
||
state.current = 'overview';
|
||
state.deviceView = 'list';
|
||
renderSidebar();
|
||
renderCurrentView();
|
||
document.getElementById('bind-panel')?.scrollIntoView({behavior:'smooth', block:'center'});
|
||
await generateBindQR();
|
||
}
|
||
|
||
async function deleteOfflineDevice(deviceId){
|
||
const device = (state.devices || []).find(item => item.device_id === deviceId);
|
||
if (!device || device.status !== 'offline') {
|
||
log('仅离线的失效设备可删除', 'error');
|
||
return;
|
||
}
|
||
if (!confirm(`确认删除失效设备 ${device.model || deviceId}?历史命令记录会保留。`)) return;
|
||
try {
|
||
const res = await apiDelete(`/api/v3/devices/${encodeURIComponent(deviceId)}`);
|
||
log(res.message || '失效设备已删除', 'info');
|
||
state.currentDevice = null;
|
||
state.deviceView = 'list';
|
||
await refreshDevices();
|
||
renderCurrentView();
|
||
} catch (e) {
|
||
log(`删除失败:${e.message || e}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function generateBindQR(){
|
||
const project = document.getElementById('bind-project')?.value?.trim() || 'cunkebao';
|
||
const name = document.getElementById('bind-name')?.value?.trim() || '工作手机';
|
||
const server = document.getElementById('bind-server')?.value?.trim();
|
||
const area = document.getElementById('bind-qr-area');
|
||
if (!server) { area.innerHTML = '<div style="color:var(--red)">请填写服务器地址</div>'; return; }
|
||
area.innerHTML = '<div style="color:var(--sub)">生成中...</div>';
|
||
try {
|
||
const payload = {project_id: project, project_name: name, server};
|
||
const shouldRetry = (e) => {
|
||
const msg = String(e?.message || '').toLowerCase();
|
||
const status = Number(e?.status || 0);
|
||
return status >= 500 || msg.includes('重连') || msg.includes('暂时') || msg.includes('超时');
|
||
};
|
||
let res;
|
||
try {
|
||
res = await apiPost('/api/v3/qrcode/generate', payload);
|
||
} catch (firstError) {
|
||
if (shouldRetry(firstError)) {
|
||
area.innerHTML = '<div style="color:var(--sub)">服务波动,重试中...</div>';
|
||
await new Promise(resolve => setTimeout(resolve, 800));
|
||
res = await apiPost('/api/v3/qrcode/generate', payload);
|
||
} else {
|
||
throw firstError;
|
||
}
|
||
}
|
||
|
||
const qr = res?.data || res || {};
|
||
const image = qr.image_data_url || (qr.image_base64 ? `data:image/png;base64,${qr.image_base64}` : '');
|
||
if (qr.success !== false && image) {
|
||
area.innerHTML = `
|
||
<img src="${image}" alt="绑定二维码" style="width:220px;height:220px;border-radius:12px;border:1px solid var(--border);margin-bottom:10px">
|
||
<div style="font-size:14px;font-weight:700;color:var(--text);margin-bottom:4px">${esc(name)}</div>
|
||
<div style="font-size:12px;color:var(--sub)">${esc(project)}</div>
|
||
<div style="font-size:10px;color:var(--muted);font-family:monospace;margin-top:4px;word-break:break-all;max-width:260px">${esc(server)}</div>
|
||
<button class="btn soft" onclick="generateBindQR()" style="margin-top:10px">重新生成</button>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const msg = qr.message || '二维码生成失败,请重试';
|
||
area.innerHTML = `<div style="color:var(--red)">${esc(msg)}</div><button class="btn soft" onclick="generateBindQR()" style="margin-top:10px">重新生成</button>`;
|
||
} catch(e) {
|
||
let tip = '服务连接异常,请稍后重试';
|
||
if (e && e.status === 401) tip = '控制台授权已失效,请重新登录;若存客宝调用返回401,请核对宝塔 .env 的 API_KEY';
|
||
else if (e && /qrcode|二维码/.test(e.message || '')) tip = `服务异常:${e.message}`;
|
||
else if (e && e.message && e.message.includes('服务正在重连')) tip = '服务正在重连,请稍后再试(建议先在控制台执行服务重启)';
|
||
else if (e && e.status && e.status >= 500) tip = '服务重启中,请稍后点击重新生成';
|
||
area.innerHTML = `<div style="color:var(--red)">${esc(tip)}</div><button class="btn soft" onclick="generateBindQR()" style="margin-top:10px">重新生成</button>`;
|
||
}
|
||
}
|
||
|
||
/* ═══ Device Actions ═══ */
|
||
|
||
async function saveDeviceRuntimeConfig(){
|
||
const did = state.currentDevice;
|
||
if (!did) return log('请先选择设备', 'error');
|
||
const imei = document.getElementById('runtime-imei')?.value?.trim() || '';
|
||
if (imei && !/^\d{14,17}$/.test(imei)) return log('IMEI 必须是14~17位数字', 'error');
|
||
const enabled = document.getElementById('runtime-wx-enabled')?.checked !== false;
|
||
const interval = Number(document.getElementById('runtime-wx-interval')?.value || 300);
|
||
try {
|
||
await apiPost(`/api/v3/devices/${did}/hot-update`, {
|
||
update_type:'config',
|
||
config:{
|
||
device_imei:imei,
|
||
wechat_keepalive_enabled:enabled,
|
||
wechat_keepalive_interval_sec:interval
|
||
}
|
||
});
|
||
log(`APK参数已更新:IMEI ${imei?'已登记':'使用系统读取'} · 微信保活 ${enabled?'开启':'关闭'} · ${interval}秒`, 'info');
|
||
setTimeout(()=>loadDeviceDetail(did), 1500);
|
||
} catch(error) {
|
||
log(`APK参数更新失败:${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function doAction(action){
|
||
if (!state.currentDevice) return log('请先选择设备', 'error');
|
||
log(`执行操作:${action}`, 'info');
|
||
setControlFeedback('指令已发送', true);
|
||
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="截图">`;
|
||
const tsEl = document.getElementById('shot-ts');
|
||
if (tsEl) tsEl.textContent = new Date().toLocaleTimeString('zh-CN',{hour12:false}) + ` · ${res.data.width||'?'}x${res.data.height||'?'}`;
|
||
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'}},
|
||
open_soul: {url:`/api/v3/adb/devices/${state.currentDevice}/app/start`, body:{package:'cn.soulapp.android'}},
|
||
home: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'home'}},
|
||
back: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'back'}},
|
||
recent: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'recent'}},
|
||
};
|
||
if (['home','back','recent'].includes(action) && remoteSend({type:'key',key:action})) {
|
||
log(`scrcpy 远程按键:${action}`,'info');
|
||
return;
|
||
}
|
||
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');
|
||
}catch(error){
|
||
log(`操作失败:${error.message}`, 'error');
|
||
}finally{
|
||
window.setTimeout(() => setControlFeedback(_remoteFirstFrame?(_remoteTransport==='agent-jpeg'?'扫码设备实时':'scrcpy 实时'):'实时在线', false), 120);
|
||
}
|
||
}
|
||
|
||
async function deviceAdminAction(action){
|
||
if (!state.currentDevice) return log('请先选择设备', 'error');
|
||
if (action === 'reboot' && !window.confirm('确认重启当前手机?设备会短暂离线后自动回连。')) return;
|
||
try {
|
||
const res = await apiPost(`/api/v3/adb/devices/${state.currentDevice}/reboot`, {});
|
||
log(res.code === 200 ? '重启指令已发送,等待手机回连' : `重启失败:${res.message || '请求失败'}`, res.code === 200 ? 'info' : 'error');
|
||
setTimeout(() => refreshDevices(true), 3500);
|
||
} catch (error) { log(`重启失败:${error.message}`, 'error'); }
|
||
}
|
||
|
||
function chooseDeviceApk(kind='app'){
|
||
state.pendingInstallKind = kind;
|
||
document.getElementById('device-apk-file')?.click();
|
||
}
|
||
|
||
async function installSelectedApk(file){
|
||
const input = document.getElementById('device-apk-file');
|
||
if (!file || !state.currentDevice) return;
|
||
if (!file.name.toLowerCase().endsWith('.apk')) { log('请选择 APK 文件', 'error'); return; }
|
||
try {
|
||
log(`正在安装${state.pendingInstallKind === 'wechat' ? '微信' : '软件'}:${file.name}`, 'info');
|
||
const res = await apiUpload(`/api/v3/adb/devices/${state.currentDevice}/app/install-ws`, file);
|
||
const ok = res.code === 200 && res.data?.success !== false;
|
||
log(ok ? `安装完成:${file.name}` : `安装失败:${res.message || res.data?.output || '设备返回失败'}`, ok ? 'info' : 'error');
|
||
if (input) input.value = '';
|
||
if (ok) setTimeout(() => loadDeviceDetail(state.currentDevice), 1200);
|
||
} catch (error) { log(`安装失败:${error.message}`, 'error'); if (input) input.value = ''; }
|
||
}
|
||
|
||
async function loadDeviceServices(deviceId=state.currentDevice){
|
||
if (!deviceId) return;
|
||
try {
|
||
await apiPost(`/api/v3/adb/devices/${deviceId}/services/ensure`, {}).catch(() => null);
|
||
const res = await apiGet(`/api/v3/adb/devices/${deviceId}/services`);
|
||
const services = res.data?.services || res.services || [];
|
||
state.devices = (state.devices || []).map(device => device.device_id === deviceId ? {...device, service_summary: services} : device);
|
||
const active = (state.devices || []).find(device => device.device_id === deviceId);
|
||
if (active) patchDeviceDetailSummary(active);
|
||
log('设备服务状态已刷新', 'info');
|
||
} catch (error) { log(`服务状态读取失败:${error.message}`, 'error'); }
|
||
}
|
||
|
||
let _phonePointerStart = null;
|
||
let _remoteSocket = null;
|
||
let _remoteDecoder = null;
|
||
let _remoteConfigPacket = null;
|
||
let _remoteVideoSize = {width:272,height:600};
|
||
let _remoteFirstFrame = false;
|
||
let _remoteMoveAt = 0;
|
||
let _remoteSerial = '';
|
||
let _remoteGeneration = 0;
|
||
let _remoteTransport = '';
|
||
|
||
function remoteSocketUrl(deviceId){
|
||
const source = API || location.origin;
|
||
const base = source.replace(/^http:/,'ws:').replace(/^https:/,'wss:');
|
||
return `${base}/api/v3/adb/devices/${encodeURIComponent(deviceId)}/screen/remote.ws`;
|
||
}
|
||
function remoteSend(message){
|
||
if (!_remoteSocket || _remoteSocket.readyState !== WebSocket.OPEN) return false;
|
||
_remoteSocket.send(JSON.stringify(message));
|
||
return true;
|
||
}
|
||
function remotePoint(event){
|
||
const screen = document.getElementById('live-screen');
|
||
if (!screen || screen.style.display === 'none') return null;
|
||
const rect = screen.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return null;
|
||
return {
|
||
x:Math.round(Math.min(1,Math.max(0,(event.clientX-rect.left)/rect.width))*_remoteVideoSize.width),
|
||
y:Math.round(Math.min(1,Math.max(0,(event.clientY-rect.top)/rect.height))*_remoteVideoSize.height)
|
||
};
|
||
}
|
||
function phonePointerStart(event){
|
||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
||
const point = remotePoint(event);
|
||
const remote = !!point && remoteSend({type:'touch',action:'down',...point,pointer_id:-1});
|
||
_phonePointerStart = {x:event.clientX,y:event.clientY,time:Date.now(),remote,lastPoint:point};
|
||
setControlFeedback(remote?'远程触控':'触控中', true);
|
||
}
|
||
function phonePointerMove(event){
|
||
if (!_phonePointerStart?.remote || Date.now()-_remoteMoveAt<16) return;
|
||
const point = remotePoint(event);
|
||
if (!point) return;
|
||
_remoteMoveAt = Date.now();
|
||
_phonePointerStart.lastPoint = point;
|
||
remoteSend({type:'touch',action:'move',...point,pointer_id:-1});
|
||
}
|
||
function phonePointerCancel(event){
|
||
if (_phonePointerStart?.remote && _phonePointerStart.lastPoint) {
|
||
remoteSend({type:'touch',action:'up',..._phonePointerStart.lastPoint,pointer_id:-1});
|
||
}
|
||
_phonePointerStart = null;
|
||
setControlFeedback(_remoteFirstFrame?(_remoteTransport==='agent-jpeg'?'扫码设备实时':'scrcpy 实时'):'实时在线', false);
|
||
}
|
||
async function phonePointerEnd(event){
|
||
if (!_phonePointerStart || !state.currentDevice) return;
|
||
const start = _phonePointerStart; _phonePointerStart = null;
|
||
const dx = event.clientX-start.x, dy = event.clientY-start.y;
|
||
const distance = Math.hypot(dx,dy);
|
||
try {
|
||
if (start.remote) {
|
||
const point = remotePoint(event) || start.lastPoint;
|
||
if (point) remoteSend({type:'touch',action:'up',...point,pointer_id:-1});
|
||
log(distance>14?'scrcpy 远程滑动已发送':'scrcpy 远程点击已发送','info');
|
||
return;
|
||
}
|
||
if (distance > 24) {
|
||
const direction = Math.abs(dx)>Math.abs(dy) ? (dx>0?'right':'left') : (dy>0?'down':'up');
|
||
await apiPost(`/api/v3/adb/devices/${state.currentDevice}/swipe`, {direction,scale:Math.min(.8,Math.max(.25,distance/420))});
|
||
log(`手机画面滑动:${direction}`,'info');
|
||
} else if (distance < 14) {
|
||
const screen = document.getElementById('live-screen');
|
||
if (!screen) return;
|
||
const rect = screen.getBoundingClientRect();
|
||
const x = Math.round(((event.clientX-rect.left)/rect.width)*(currentDeviceDisplay().width||1080));
|
||
const y = Math.round(((event.clientY-rect.top)/rect.height)*(currentDeviceDisplay().height||2400));
|
||
await apiPost(`/api/v3/adb/devices/${state.currentDevice}/click`, {x,y});
|
||
log(`手机画面点击:${x}, ${y}`,'info');
|
||
}
|
||
} catch(error) {
|
||
log(`手机画面操作失败:${error.message}`,'error');
|
||
} finally {
|
||
window.setTimeout(()=>setControlFeedback(_remoteFirstFrame?(_remoteTransport==='agent-jpeg'?'扫码设备实时':'scrcpy 实时'):'实时在线',false),80);
|
||
}
|
||
}
|
||
function currentDeviceDisplay(){
|
||
const device = (state.devices || []).find(item => item.device_id === state.currentDevice) || {};
|
||
return device.display || {width:1080,height:2400};
|
||
}
|
||
|
||
function liveScreenUrl(){
|
||
if (!state.currentDevice) return '';
|
||
return `${API}/api/v3/adb/devices/${encodeURIComponent(state.currentDevice)}/screen/live.mjpeg?fps=3&quality=65&max_width=600&t=${Date.now()}`;
|
||
}
|
||
function liveScreenReady(){
|
||
const status = document.getElementById('live-screen-status');
|
||
if (status) { status.className = 'live-device-status'; status.textContent = _remoteTransport==='agent-jpeg' ? '扫码设备实时' : 'scrcpy 实时'; }
|
||
}
|
||
function setControlFeedback(text, active=false){
|
||
const status = document.getElementById('live-screen-status');
|
||
const frame = document.getElementById('shot-box');
|
||
if (status) {
|
||
status.className = active ? 'live-device-status waiting' : 'live-device-status';
|
||
status.textContent = text;
|
||
}
|
||
frame?.classList.toggle('control-feedback', active);
|
||
}
|
||
function startRemoteScreen(deviceId=state.currentDevice){
|
||
if (!deviceId || !document.getElementById('live-screen')) return;
|
||
const generation = ++_remoteGeneration;
|
||
if (_remoteSocket) { try{_remoteSocket.close()}catch(_){} }
|
||
if (_remoteDecoder) { try{_remoteDecoder.close()}catch(_){} }
|
||
_remoteSerial = deviceId;
|
||
_remoteTransport = '';
|
||
_remoteFirstFrame = false;
|
||
_remoteConfigPacket = null;
|
||
const canvas = document.getElementById('live-screen');
|
||
const fallback = document.getElementById('live-screen-fallback');
|
||
if (canvas) canvas.style.display = 'block';
|
||
if (fallback) fallback.style.display = 'none';
|
||
setControlFeedback('远程连接中',true);
|
||
if (!('VideoDecoder' in window) || !('EncodedVideoChunk' in window)) {
|
||
startFallbackScreen('浏览器解码兼容模式');
|
||
return;
|
||
}
|
||
const context = canvas.getContext('2d',{alpha:false,desynchronized:true});
|
||
_remoteDecoder = new VideoDecoder({
|
||
output(frame){
|
||
if (canvas.width!==frame.displayWidth || canvas.height!==frame.displayHeight) {
|
||
canvas.width=frame.displayWidth; canvas.height=frame.displayHeight;
|
||
}
|
||
context.drawImage(frame,0,0,canvas.width,canvas.height);
|
||
frame.close();
|
||
if (!_remoteFirstFrame) {
|
||
_remoteFirstFrame=true;
|
||
liveScreenReady();
|
||
}
|
||
},
|
||
error(error){
|
||
log(`scrcpy 解码异常:${error.message}`,'error');
|
||
startFallbackScreen('视频解码兼容模式');
|
||
}
|
||
});
|
||
const socket = new WebSocket(remoteSocketUrl(deviceId));
|
||
_remoteSocket = socket;
|
||
socket.binaryType = 'arraybuffer';
|
||
socket.onmessage = event => {
|
||
if (generation !== _remoteGeneration) return;
|
||
if (typeof event.data === 'string') {
|
||
const message = JSON.parse(event.data);
|
||
if (message.type==='init') {
|
||
_remoteTransport=message.transport || 'scrcpy-h264';
|
||
_remoteVideoSize={width:Number(message.width)||272,height:Number(message.height)||600};
|
||
canvas.width=_remoteVideoSize.width; canvas.height=_remoteVideoSize.height;
|
||
if (message.transport==='agent-jpeg') {
|
||
_remoteDecoder.close(); _remoteDecoder=null;
|
||
setControlFeedback('扫码设备连接中',true);
|
||
} else {
|
||
_remoteDecoder.configure({codec:message.codec||'avc1.64000A',optimizeForLatency:true,hardwareAcceleration:'prefer-hardware'});
|
||
setControlFeedback('视频接收中',true);
|
||
}
|
||
} else if (message.type==='config' && _remoteDecoder?.state!=='closed') {
|
||
try{_remoteDecoder.configure({codec:message.codec||'avc1.64000A',optimizeForLatency:true,hardwareAcceleration:'prefer-hardware'})}catch(_){}
|
||
} else if (message.type==='error') {
|
||
log(`scrcpy 连接失败:${message.message}`,'error');
|
||
}
|
||
return;
|
||
}
|
||
const bytes = new Uint8Array(event.data);
|
||
if (bytes.byteLength<10) return;
|
||
const view = new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
|
||
const kind = view.getUint8(0);
|
||
const timestamp = Number(view.getBigUint64(1,false));
|
||
const payload = bytes.slice(9);
|
||
if (kind===3) {
|
||
createImageBitmap(new Blob([payload],{type:'image/jpeg'})).then(frame=>{
|
||
if (generation!==_remoteGeneration) { frame.close(); return; }
|
||
if (canvas.width!==frame.width || canvas.height!==frame.height) { canvas.width=frame.width; canvas.height=frame.height; }
|
||
context.drawImage(frame,0,0,canvas.width,canvas.height); frame.close();
|
||
if (!_remoteFirstFrame) { _remoteFirstFrame=true; liveScreenReady(); }
|
||
}).catch(()=>{ if (!_remoteFirstFrame) startFallbackScreen('远程画面重试'); });
|
||
return;
|
||
}
|
||
if (!_remoteDecoder || _remoteDecoder.state!=='configured') return;
|
||
if (kind===0) { _remoteConfigPacket=payload; return; }
|
||
let data=payload;
|
||
if (kind===1 && _remoteConfigPacket) {
|
||
data=new Uint8Array(_remoteConfigPacket.byteLength+payload.byteLength);
|
||
data.set(_remoteConfigPacket); data.set(payload,_remoteConfigPacket.byteLength);
|
||
}
|
||
try {
|
||
_remoteDecoder.decode(new EncodedVideoChunk({type:kind===1?'key':'delta',timestamp,duration:33333,data}));
|
||
} catch(error) {
|
||
if (!_remoteFirstFrame) startFallbackScreen('视频解码兼容模式');
|
||
}
|
||
};
|
||
socket.onerror = () => {
|
||
if (generation !== _remoteGeneration) return;
|
||
if (!_remoteFirstFrame) startFallbackScreen('远程视频兼容模式');
|
||
};
|
||
socket.onclose = () => {
|
||
if (generation !== _remoteGeneration) return;
|
||
if (!_remoteFirstFrame && _remoteSerial===deviceId) startFallbackScreen('远程视频兼容模式');
|
||
else if (_remoteSerial===deviceId) setControlFeedback('远程已断开',false);
|
||
};
|
||
}
|
||
function startFallbackScreen(reason='兼容模式'){
|
||
++_remoteGeneration;
|
||
if (_remoteSocket) { try{_remoteSocket.close()}catch(_){} }
|
||
const canvas=document.getElementById('live-screen');
|
||
const image=document.getElementById('live-screen-fallback');
|
||
if (canvas) canvas.style.display='none';
|
||
if (image) { image.style.display='block'; image.src=liveScreenUrl(); }
|
||
const status=document.getElementById('live-screen-status');
|
||
if (status) { status.className='live-device-status waiting'; status.textContent=reason; }
|
||
}
|
||
function fallbackScreenReady(){
|
||
const status=document.getElementById('live-screen-status');
|
||
if (status) { status.className='live-device-status waiting'; status.textContent='兼容画面'; }
|
||
}
|
||
function liveScreenFailed(image){
|
||
const status = document.getElementById('live-screen-status');
|
||
if (status) { status.className = 'live-device-status error'; status.textContent = '连接重试'; }
|
||
window.setTimeout(() => {
|
||
if (image?.isConnected && state.currentDevice) image.src = liveScreenUrl();
|
||
}, 1600);
|
||
}
|
||
function restartLiveScreen(){
|
||
const status = document.getElementById('live-screen-status');
|
||
if (status) { status.className = 'live-device-status waiting'; status.textContent = '正在重连'; }
|
||
startRemoteScreen(state.currentDevice);
|
||
log('scrcpy 远程视频正在重新连接','info');
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
/* ═══ Device Management ═══ */
|
||
|
||
function selectDevice(deviceId){
|
||
state.currentDevice = deviceId;
|
||
renderCurrentView();
|
||
window.setTimeout(() => {
|
||
if (state.current === 'devices' && state.currentDevice === deviceId) loadDeviceDetail(deviceId);
|
||
}, 3000);
|
||
log(`已切换设备:${deviceId}`, 'info');
|
||
}
|
||
|
||
async function loadDeviceDetail(deviceId){
|
||
try{
|
||
const preserveLiveScreen = state.current === 'devices'
|
||
&& state.deviceView === 'detail'
|
||
&& state.currentDevice === deviceId
|
||
&& !!document.getElementById('live-screen');
|
||
const [detailRes, modesRes, servicesRes] = await Promise.all([
|
||
apiGet(`/api/v3/devices/${deviceId}`),
|
||
apiGet(`/api/v3/connection/modes/${deviceId}`).catch(() => null),
|
||
apiGet(`/api/v3/adb/devices/${deviceId}/services`).catch(() => null)
|
||
]);
|
||
let detail = detailRes.data || {};
|
||
const modes = modesRes?.data || {};
|
||
const services = servicesRes?.data || servicesRes || {};
|
||
const needAdb = !detail.model || String(detail.model).includes('Unknown') || !detail.brand;
|
||
if (needAdb) {
|
||
try {
|
||
const adbRes = await apiGet(`/api/v3/adb/devices/${deviceId}`);
|
||
const adbData = adbRes.data || adbRes;
|
||
if (adbData.model && !String(adbData.model).includes('Unknown')) detail = {...detail, model: adbData.model};
|
||
if (adbData.brand && adbData.brand !== 'Unknown') detail = {...detail, brand: adbData.brand};
|
||
if (adbData.android_version && adbData.android_version !== 'Unknown') detail = {...detail, android_version: adbData.android_version};
|
||
} catch(_) {}
|
||
}
|
||
const merged = {
|
||
...detail,
|
||
device_id: deviceId,
|
||
connection_modes: modes.modes || [],
|
||
best_mode: modes.best_mode || null,
|
||
service_summary: services.services || detail.service_summary || []
|
||
};
|
||
state.devices = (state.devices || []).map(item => item.device_id === deviceId ? {...item, ...merged} : item);
|
||
if (preserveLiveScreen) patchDeviceDetailSummary(merged);
|
||
else if (state.current === 'devices') renderCurrentView();
|
||
}catch(error){
|
||
log(`设备详情加载失败:${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
function patchDeviceDetailSummary(device){
|
||
const setText = (id, value) => {
|
||
const element = document.getElementById(id);
|
||
if (element && value !== undefined && value !== null && value !== '') element.textContent = String(value);
|
||
};
|
||
setText('device-summary-name', device.profile_nickname || device.nickname || device.model || device.device_id);
|
||
setText('device-summary-id', `${[device.brand,device.model].filter(Boolean).join(' ') || 'Android'} · ${device.device_id}`);
|
||
setText('device-metric-android', device.android_version || device.sdk_version || '-');
|
||
setText('device-metric-app', device.current_app || (device.wechat_running ? '微信' : '-'));
|
||
setText('device-metric-battery', device.battery_level >= 0 ? `${device.battery_level}%${device.battery_charging ? ' ⚡' : ''}` : '-');
|
||
const services = Array.isArray(device.service_summary) ? device.service_summary : [];
|
||
const serviceRow = document.querySelector('.service-summary-row');
|
||
if (serviceRow && services.length) {
|
||
serviceRow.innerHTML = services.map(service => `<span class="service-chip ${service.running?'ready':''}"><i></i>${esc(service.name)} · ${service.running?'运行中':'待配置'}</span>`).join('');
|
||
}
|
||
}
|
||
|
||
async function adbRescan(){
|
||
log('正在扫描 ADB 设备...', 'info');
|
||
try {
|
||
const res = await apiPost('/api/v3/adb/scan');
|
||
const found = (res.data?.devices || []).length;
|
||
log(`ADB 扫描完成,发现 ${found} 台设备`, 'info');
|
||
await refreshDevices();
|
||
} catch(e) {
|
||
log(`ADB 扫描失败:${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/* ═══ Refresh Functions ═══ */
|
||
|
||
async function refreshDevices(silent){
|
||
await Promise.all([refreshOverview(silent), refreshConnection(silent)]);
|
||
await refreshRealtimeStatus(silent);
|
||
if (!silent && state.currentDevice) await loadDeviceDetail(state.currentDevice);
|
||
}
|
||
|
||
async function refreshAll(){
|
||
await Promise.all([refreshOverview(), refreshConnection(), refreshDocs()]);
|
||
await refreshRealtimeStatus();
|
||
log('全部数据已刷新', 'info');
|
||
}
|
||
|
||
async function refreshRealtimeStatus(silent=true){
|
||
try {
|
||
const res = await apiGet('/api/v3/integration/realtime/status?consumer=cunkebao&event_limit=20');
|
||
const data = res.data || res || {};
|
||
state.realtime = {
|
||
devices: Array.isArray(data.devices) ? data.devices : [],
|
||
recent_events: Array.isArray(data.recent_events) ? data.recent_events : [],
|
||
};
|
||
state.realtimeError = '';
|
||
const liveById = new Map(state.realtime.devices.map(item => [item.device_id, item]));
|
||
state.devices = (state.devices || []).map(device => {
|
||
const live = liveById.get(device.device_id);
|
||
return live ? {...device, ...live, status: live.status || device.status} : device;
|
||
});
|
||
if (!silent && state.current === 'devices') renderCurrentView(false);
|
||
} catch (error) {
|
||
state.realtimeError = error.message || String(error);
|
||
if (!silent) log(`实时状态加载失败:${state.realtimeError}`, 'error');
|
||
}
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
let _refreshLock = false;
|
||
async function refreshOverview(silent){
|
||
if (_refreshLock) return;
|
||
_refreshLock = true;
|
||
try{
|
||
const res = await apiGet('/api/v3/workbench/overview');
|
||
state.overview = res.data;
|
||
state.devices = res.data.devices || [];
|
||
if (state.devices.length && !state.devices.some(device => device.device_id === state.currentDevice)) {
|
||
state.currentDevice = state.devices[0].device_id;
|
||
}
|
||
renderStats();
|
||
if (!silent) renderCurrentView();
|
||
}catch(error){
|
||
if (!silent) log(`工作台概览加载失败:${error.message}`, 'error');
|
||
} finally {
|
||
_refreshLock = false;
|
||
}
|
||
}
|
||
|
||
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','engine','infra'].includes(state.current)) {
|
||
const dv = document.getElementById('doc-view');
|
||
if (dv) delete dv.dataset.loaded;
|
||
renderCurrentView();
|
||
}
|
||
}catch(error){
|
||
log(`文档清单加载失败:${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function refreshConnection(silent){
|
||
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 (!silent && ['devices','infra'].includes(state.current)) renderCurrentView(false);
|
||
}catch(error){
|
||
if (!silent) 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 === 'engine') renderCurrentView();
|
||
}catch(error){
|
||
log(`Hook 数据加载失败:${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function refreshHeartbeat(){
|
||
try {
|
||
const [dashRes, eventsRes] = await Promise.all([
|
||
apiGet('/api/v3/heartbeat/dashboard'),
|
||
apiGet('/api/v3/heartbeat/events?limit=30')
|
||
]);
|
||
state.heartbeatDashboard = dashRes.data || {};
|
||
state.heartbeatEvents = eventsRes.data || [];
|
||
if (state.current === 'engine' && ['heartbeat','connection'].includes(state.engineTab)) renderCurrentView();
|
||
} catch(e) {
|
||
log('心跳监控数据加载失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function refreshAntiban(){
|
||
try {
|
||
const qs = state.currentDevice ? `?device_id=${encodeURIComponent(state.currentDevice)}` : '';
|
||
const res = await apiGet(`/api/v3/antiban/status${qs}`);
|
||
state.antibanStatus = res.data || {};
|
||
if (state.current === 'engine' && state.engineTab === 'antiban') renderCurrentView(false);
|
||
} catch(e) {
|
||
log('防封状态加载失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
/* ═══ View Switching ═══ */
|
||
|
||
let _lastView = '';
|
||
function renderCurrentView(animate){
|
||
const view = document.getElementById('view');
|
||
const stats = document.getElementById('stats');
|
||
if (stats) stats.style.display = 'none';
|
||
const renderers = {
|
||
overview: renderOverviewClean,
|
||
devices: renderDevicePanel,
|
||
engine: renderEnginePanel,
|
||
gateway: renderGatewayPanel,
|
||
docs: renderDocsPanel,
|
||
};
|
||
const fn = renderers[state.current];
|
||
if (fn) view.innerHTML = fn();
|
||
const isNewView = _lastView !== state.current;
|
||
_lastView = state.current;
|
||
if (animate !== false && isNewView) {
|
||
view.style.animation = 'none';
|
||
void view.offsetHeight;
|
||
view.style.animation = 'fadeUp .35s cubic-bezier(.22,1,.36,1) both';
|
||
}
|
||
}
|
||
|
||
/* ═══ 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.id !== 'live-screen' && (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();
|
||
});
|
||
|
||
/* ═══ Screenshot Auto-Refresh ═══ */
|
||
|
||
let _shotTimer = null;
|
||
let _shotInterval = 5;
|
||
state.shotAutoRefresh = false;
|
||
|
||
function toggleShotAutoRefresh() {
|
||
state.shotAutoRefresh = !state.shotAutoRefresh;
|
||
if (state.shotAutoRefresh) {
|
||
doAction('screenshot');
|
||
_shotTimer = setInterval(() => {
|
||
if (state.currentDevice && state.current === 'devices' && state.deviceView === 'detail') {
|
||
doAction('screenshot');
|
||
}
|
||
}, _shotInterval * 1000);
|
||
log(`截图自动刷新已开启 (每${_shotInterval}秒)`, 'info');
|
||
} else {
|
||
if (_shotTimer) { clearInterval(_shotTimer); _shotTimer = null; }
|
||
log('截图自动刷新已关闭', 'info');
|
||
}
|
||
const btn = document.getElementById('shot-auto-btn');
|
||
if (btn) {
|
||
btn.textContent = state.shotAutoRefresh ? '⏸ 停止刷新' : '▶ 自动刷新';
|
||
btn.style.background = state.shotAutoRefresh ? 'rgba(22,163,74,.1)' : 'rgba(15,23,42,.04)';
|
||
btn.style.color = state.shotAutoRefresh ? 'var(--green)' : 'var(--sub)';
|
||
}
|
||
}
|
||
|
||
function setShotInterval(sec) {
|
||
_shotInterval = sec;
|
||
if (state.shotAutoRefresh) {
|
||
if (_shotTimer) clearInterval(_shotTimer);
|
||
_shotTimer = setInterval(() => {
|
||
if (state.currentDevice && state.current === 'devices' && state.deviceView === 'detail') {
|
||
doAction('screenshot');
|
||
}
|
||
}, _shotInterval * 1000);
|
||
}
|
||
}
|
||
|
||
/* ═══ LAN Discovery & Auto-Connect ═══ */
|
||
|
||
state.discoveryStatus = 'idle';
|
||
state.discoveredServers = [];
|
||
state.serverInfo = null;
|
||
|
||
async function scanLanDevices() {
|
||
state.discoveryStatus = 'scanning';
|
||
updateConnBar();
|
||
const ds = document.getElementById('cb-discovery-status');
|
||
if (ds) ds.style.display = '';
|
||
log('正在扫描局域网设备...', 'info');
|
||
try {
|
||
const res = await apiPost('/api/v3/discovery/scan-lan?timeout=3');
|
||
state.discoveredServers = res.data?.servers || [];
|
||
const preferred = state.discoveredServers.find(server => server.is_self) || state.discoveredServers[0];
|
||
if (preferred?.ws_url) state.bindingServer = preferred.ws_url.replace(/\/ws\/device\/?$/, '/ws/device');
|
||
state.discoveryStatus = 'done';
|
||
log(`局域网扫描完成:发现 ${state.discoveredServers.length} 个服务,绑定基准已切换到 ${state.bindingServer || '当前服务器'}`, 'info');
|
||
} catch(e) {
|
||
state.discoveryStatus = 'error';
|
||
log(`局域网扫描失败:${e.message}`, 'error');
|
||
}
|
||
if (ds) ds.style.display = 'none';
|
||
updateConnBar();
|
||
renderCurrentView();
|
||
}
|
||
|
||
async function loadServerInfo() {
|
||
try {
|
||
const res = await apiGet('/api/v3/discovery/server-info');
|
||
state.serverInfo = res.data || {};
|
||
const ipEl = document.getElementById('cb-server-ip');
|
||
if (ipEl && state.serverInfo.ips?.length) {
|
||
ipEl.textContent = `服务器: ${state.serverInfo.ips[0]}:${state.serverInfo.port}`;
|
||
}
|
||
} catch(_) {}
|
||
}
|
||
|
||
function updateConnBar() {
|
||
const ov = state.overview || {};
|
||
const wsCount = ov.ws_online ?? 0;
|
||
const adbCount = ov.adb_count ?? 0;
|
||
const wsDot = document.getElementById('cb-ws-dot');
|
||
const wsText = document.getElementById('cb-ws-text');
|
||
const adbDot = document.getElementById('cb-adb-dot');
|
||
const adbText = document.getElementById('cb-adb-text');
|
||
const beaconDot = document.getElementById('cb-beacon-dot');
|
||
const beaconText = document.getElementById('cb-beacon-text');
|
||
|
||
if (wsDot) { wsDot.className = `conn-dot ${wsCount > 0 ? 'green' : 'red'}`; }
|
||
if (wsText) { wsText.textContent = `WS ${wsCount} 在线`; }
|
||
if (adbDot) { adbDot.className = `conn-dot ${adbCount > 0 ? 'green' : 'yellow'}`; }
|
||
if (adbText) { adbText.textContent = `ADB ${adbCount} 台`; }
|
||
if (beaconDot) { beaconDot.className = `conn-dot ${state.serverInfo?.beacon_running ? 'green' : 'yellow'}`; }
|
||
if (beaconText) { beaconText.textContent = state.serverInfo?.beacon_running ? 'Beacon 广播中' : 'Beacon 未启动'; }
|
||
}
|
||
|
||
/* ═══ Bootstrap & Polling ═══ */
|
||
|
||
async function bootstrap(){
|
||
renderSidebar();
|
||
if (!await ensureConsoleAuthenticated()) {
|
||
document.getElementById('view').innerHTML = `<section class="panel glass" style="text-align:center;padding:64px 24px">
|
||
<div style="font-size:44px;margin-bottom:12px">🔐</div>
|
||
<h2>登录工作手机控制台</h2>
|
||
<p class="sub">管理员账号用于网页控制台;API Key 仅放在存客宝后端或宝塔环境变量中。</p>
|
||
<div style="max-width:360px;margin:20px auto 0;display:grid;gap:10px;text-align:left">
|
||
<input id="console-username" value="admin" autocomplete="username" placeholder="账号" style="padding:12px 14px;border-radius:12px;border:1px solid var(--border)">
|
||
<input id="console-password" type="password" autocomplete="current-password" placeholder="密码" onkeydown="if(event.key==='Enter')loginConsole()" style="padding:12px 14px;border-radius:12px;border:1px solid var(--border)">
|
||
<div id="console-login-error" style="min-height:20px;color:var(--red);font-size:12px"></div>
|
||
<button class="btn primary" onclick="loginConsole()">登录并加载设备</button>
|
||
</div>
|
||
<div style="max-width:620px;margin:22px auto 0;text-align:left;background:rgba(37,99,235,.06);border:1px solid rgba(37,99,235,.16);border-radius:14px;padding:14px 16px;font-size:12px;line-height:1.8;color:var(--sub)">
|
||
<b style="color:var(--text)">授权配置位置</b><br>
|
||
1. 宝塔:<code>/www/wwwroot/workphone-sdk/.env</code> 配置 <code>API_KEY</code>、<code>DEVICE_PAIRING_TOKEN</code>、<code>CONSOLE_PASSWORD</code>,然后重启 <code>workphone-sdk-baota</code>。<br>
|
||
2. 存客宝后端:配置 <code>WORKPHONE_SDK_URL=https://wpsdk.quwanzhi.com</code>、<code>WORKPHONE_SDK_KEY=同一API_KEY</code>。<br>
|
||
3. 手机扫码:登录后进入“手机设备”,点击“生成绑定二维码”;二维码只包含工作手机服务地址与配对令牌,不包含 API Key。
|
||
</div>
|
||
</section>`;
|
||
updateConsoleAuthChip(false);
|
||
return false;
|
||
}
|
||
await Promise.all([
|
||
refreshOverview(),
|
||
refreshDocs(),
|
||
refreshConnection(),
|
||
refreshHook(),
|
||
refreshHeartbeat(),
|
||
refreshAntiban(),
|
||
refreshGateway(),
|
||
loadBrainDashboard(),
|
||
loadServerInfo(),
|
||
]);
|
||
await refreshRealtimeStatus();
|
||
if (state.currentDevice) await loadDeviceDetail(state.currentDevice);
|
||
renderCurrentView();
|
||
updateConnBar();
|
||
setTimeout(() => {
|
||
const ov = state.overview || {};
|
||
if ((ov.ws_online ?? 0) === 0 && (ov.adb_count ?? 0) === 0) {
|
||
log('未检测到在线设备,正在自动扫描局域网...', 'info');
|
||
scanLanDevices();
|
||
}
|
||
}, 2000);
|
||
return true;
|
||
}
|
||
|
||
bootstrap().then(ok => { if (ok) startPolling(); });
|
||
|
||
let _pollTimer = null;
|
||
function startPolling(){
|
||
if (_pollTimer) clearInterval(_pollTimer);
|
||
_pollTimer = setInterval(async () => {
|
||
await refreshOverview(true);
|
||
await refreshRealtimeStatus(true);
|
||
updateConnBar();
|
||
}, 30000);
|
||
}
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) { clearInterval(_pollTimer); _pollTimer = null; }
|
||
else if (consoleAuthenticated) { startPolling(); refreshOverview(true).then(() => refreshRealtimeStatus(true)).then(updateConnBar); }
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|