Files
butubb beb51637dd feat(设备): 电量显示(大屏 + 监控页)+ 低电量 webhook 告警
需求:大屏和监控页都要能看到每台设备的电量,并且"低于多少电量就发通知"。

数据从哪来:`adb -s <serial> shell dumpsys battery`(**只读**,实测 0.2~0.35s/台,
11 台并发一轮 0.44s)。设备只要在 `adb devices` 里就说明 transport 现成,
**不需要 connect**——所以连空闲设备也能查。这是本模块敢每轮扫全量的前提,
也是这条只读路径与"空闲设备不主动 connect"红线(DEVELOPMENT.md §2 #3)的边界,
已写进文档免得后人误加 connect。

- core/device_battery.py(新):后台 daemon 线程 `device-battery`(照 device_discovery
  的 init_app/shutdown 模式),默认 60s 一轮,采「设备池 ∩ 在线」(与 list_ready 同口径,
  adb 可见但不归平台管的设备不上屏也不告警)。
  · 结果**只放内存**(不落库 → 不建表、不动备份覆盖清单),离线设备保留最后读数;
  · 读失败不清缓存:list_devices 失败时若照清会把整份缓存抹掉、大屏全体变「—」;
  · 配置存 `app_meta.device_battery`(照 step_defaults:出厂值 + 白名单 + 范围钳制 +
    只落与出厂值不同的字段),改阈值不用重启(线程每轮重读配置)。
- 告警:事件 `device.battery.low` / `device.battery.recovered`(默认关,去通知页勾订阅)。
  **按档位做状态沿**(0 正常 / 1 低 / 2 严重):掉档才报、严重再报一次、恢复报一次;
  迟滞 +5% 避免在阈值上下反复告警;同档位每 6h 重复提醒一次。
  "充电中"看的是 `status:` 行(2/5),**不是"插着电"**——所以"插着却没充电"
  (劣质线/温控/满电停充 status:4)仍会告警,那正是最该知道的情况(实测 fleet 里
  就有一台 8% 插着 AC 但 `Max charging current: 0`)。
- GET /api/status 每台设备多一个 `battery`:`{level,charging,at,tier}`。
  **tier 由后端算好**,前端只按它上色——阈值只在「工具 → 设备发现 → 电量监控」一处定义,
  免得 JS 里再判一遍、两边不一致。
- 前端:大屏卡片型号行右侧加电量徽标(绿/黄/红 + ⚡ 充电中,离线置灰),
  `cardHtml`/`renderGrid` 增量更新/脏检查白名单三处都改了(漏一处就不刷新);
  监控页设备表新增可排序的「电量」列(空表 colspan 11 顺带对齐了)。
- 接口:GET/POST /api/devices/battery[/settings] + POST /scan(需设备权限,
  与 /api/devices/discovery/* 同权限档)。

自测:单元 30 项(解析真机输出/档位/状态机/钳制,含 scale=255、无 status 行、
垃圾输出等边界)、集成 27 项(临时库自建设备池:缓存/通知/离线保留/删设备清理/
配置往返/阈值改动即时生效)、真实 11 台设备一轮 0.44s 全读到、API 端到端
(登录/权限/钳制/越界/REST 往返/页面 DOM)、两份前端各自语法检查 + 渲染用例。
2026-09-24 08:37:42 +08:00

513 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}设备监控大屏</title>
<style>
/* ========== 监控大屏:深色控制室风格 ========== */
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:800;font-display:swap;src:url('/static/fonts/bricolage-800.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/static/fonts/plexmono-400.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('/static/fonts/plexmono-600.woff2') format('woff2')}
:root{
--bg:#0d0f13; --card:#14181f; --card-line:#232936; --ink-line:#2a303c;
--text:#e8ecf2; --muted:#8b93a3; --faint:#5c6472;
--teal:#2dd4bf; --green:#34d399; --red:#f87171; --amber:#fbbf24; --indigo:#818cf8;
--display:'Bricolage Grotesque','PingFang SC','Microsoft YaHei',sans-serif;
--mono:'IBM Plex Mono',Menlo,Consolas,monospace;
--body:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);font-family:var(--body);color:var(--text);overflow:hidden}
body::before{content:"";position:fixed;inset:0;pointer-events:none;
background-image:linear-gradient(rgba(45,212,191,.035) 1px,transparent 1px),
linear-gradient(90deg,rgba(45,212,191,.035) 1px,transparent 1px);
background-size:42px 42px}
/* 顶部栏 */
header{display:flex;align-items:center;gap:24px;padding:14px 26px 12px;border-bottom:1px solid var(--ink-line);background:rgba(13,15,19,.85);backdrop-filter:blur(6px);position:relative;z-index:5}
header .title{font-family:var(--display);font-size:21px;font-weight:800;letter-spacing:1px;color:#fff;display:flex;align-items:center;gap:12px;white-space:nowrap}
header .title::before{content:"";width:10px;height:10px;border-radius:50%;background:var(--teal);box-shadow:0 0 0 0 rgba(45,212,191,.55);animation:pulse 2.2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(45,212,191,.5)}70%{box-shadow:0 0 0 9px rgba(45,212,191,0)}100%{box-shadow:0 0 0 0 rgba(45,212,191,0)}}
header .stats{display:flex;gap:10px;flex:1}
header .stat{background:var(--card);border:1px solid var(--card-line);border-radius:10px;padding:6px 16px;text-align:center;min-width:84px}
header .stat .n{font-family:var(--mono);font-size:20px;font-weight:600;line-height:1.15}
header .stat .l{font-size:11px;color:var(--faint);margin-top:2px;letter-spacing:.4px}
header .stat.online .n{color:var(--green)}
header .stat.running .n{color:var(--teal)}
header .stat.error .n{color:var(--red)}
header .wall-btn{background:var(--card);border:1px solid var(--card-line);color:var(--teal);border-radius:10px;padding:8px 18px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--body)}
header .wall-btn:hover{background:#1a2029;border-color:var(--teal);transform:translateY(-1px)}
header .wall-btn:disabled{opacity:.5;cursor:not-allowed}
header .clock{font-family:var(--mono);font-size:18px;color:var(--muted);text-align:right;line-height:1.2;white-space:nowrap}
header .clock .date{font-size:11px;color:var(--faint)}
/* 设备网格 */
main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;padding:16px 24px 30px;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;align-content:start}
/* 设备卡片(可点击进操作) */
.card{background:var(--card);border:1px solid var(--card-line);border-radius:14px;overflow:hidden;position:relative;animation:cardIn .4s ease-out backwards;cursor:pointer;transition:border-color .2s,box-shadow .2s,transform .15s}
.card:hover{transform:translateY(-3px);box-shadow:0 8px 22px rgba(0,0,0,.4)}
@keyframes cardIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.card.running{border-color:rgba(45,212,191,.55);box-shadow:0 0 18px rgba(45,212,191,.12),inset 0 0 0 1px rgba(45,212,191,.2)}
.card.error{border-color:rgba(248,113,113,.5)}
.card.offline{opacity:.62}
.card .thumb{aspect-ratio:9/16;background:#0a0c10;position:relative;overflow:hidden}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:opacity .2s}
.card.offline .thumb img{display:none}
.card .thumb .ph{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--faint);font-size:12px;background:repeating-linear-gradient(45deg,#0c0e12,#0c0e12 12px,#0f1218 12px,#0f1218 24px)}
.card .thumb .ph .ico{font-size:26px;opacity:.6}
/* 状态角标 */
.card .badge{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:6px;background:rgba(13,15,19,.8);backdrop-filter:blur(4px);border-radius:99px;padding:3px 10px;font-size:11px;font-weight:600;color:var(--muted);border:1px solid var(--ink-line)}
.card.running .badge{color:var(--teal);border-color:rgba(45,212,191,.4)}
.card.error .badge{color:var(--red)}
.card .badge .dot{width:7px;height:7px;border-radius:50%;background:var(--faint)}
.card.running .badge .dot{background:var(--teal);box-shadow:0 0 7px rgba(45,212,191,.8);animation:pulse 1.6s infinite}
.card.error .badge .dot{background:var(--red);box-shadow:0 0 6px rgba(248,113,113,.7)}
.card.offline .badge .dot{background:var(--faint)}
/* 熄屏角标(右上) */
.card .screen-tag{position:absolute;top:8px;right:8px;background:rgba(13,15,19,.8);backdrop-filter:blur(4px);border-radius:99px;padding:3px 10px;font-size:11px;font-weight:600;border:1px solid var(--ink-line)}
.card .screen-tag.on{color:var(--green)}
.card .screen-tag.off{color:var(--faint)}
/* 信息区 */
.card .info{padding:9px 12px 11px}
.card .info .name{font-size:13.5px;font-weight:700;color:#f1f4f8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .info .ip{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .info .model{font-size:10.5px;color:var(--faint);margin-top:2px}
/* 型号 + 电量同一行(省一行高度,卡片本来就矮) */
.card .info .meta{display:flex;align-items:center;gap:8px;margin-top:2px;white-space:nowrap;overflow:hidden}
.card .info .meta .model{margin-top:0;overflow:hidden;text-overflow:ellipsis}
.card .info .batt{margin-left:auto;display:inline-flex;align-items:center;gap:2px;font-size:11px;color:var(--faint);flex:0 0 auto}
.card .info .batt .lv{font-family:var(--mono);font-weight:700}
.card .info .batt.ok .lv{color:var(--green)}
.card .info .batt.low .lv{color:var(--amber)}
.card .info .batt.crit .lv{color:var(--red)}
.card .info .batt.crit{animation:pulse 1.6s infinite}
.card .info .batt.stale{opacity:.4}
.card .info .batt .chg{color:var(--teal)}
.card .info .action{font-size:11.5px;color:var(--muted);margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:16px}
.card .info .action b{color:var(--amber);font-weight:600}
.card .info .prog{margin-top:7px;height:4px;background:#1d242e;border-radius:99px;overflow:hidden}
.card .info .prog i{display:block;height:100%;background:linear-gradient(90deg,var(--teal),#5eead4);border-radius:99px;transition:width .5s}
.card .info .prog-txt{font-size:10px;color:var(--faint);margin-top:3px;font-family:var(--mono)}
.card .info .hint{font-size:10px;color:var(--faint);margin-top:5px;text-align:center;opacity:0;transition:opacity .2s}
.card:hover .info .hint{opacity:.8}
/* 空状态 */
.empty-wall{grid-column:1/-1;text-align:center;color:var(--faint);padding:80px 0;font-size:14px}
/* 设备操作弹窗 */
.ctrl-overlay{display:none;position:fixed;inset:0;background:rgba(5,7,10,.78);backdrop-filter:blur(3px);z-index:100;justify-content:center;align-items:center;padding:26px}
.ctrl-overlay.show{display:flex}
.ctrl-box{background:var(--card);border:1px solid var(--card-line);border-radius:16px;width:min(420px,94vw);max-height:94vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 70px rgba(0,0,0,.6);animation:modalIn .18s ease-out}
@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.98)}to{opacity:1;transform:none}}
.ctrl-head{padding:12px 16px;border-bottom:1px solid var(--ink-line);display:flex;align-items:center;gap:12px;background:#11151c}
.ctrl-head .info{flex:1;min-width:0}
.ctrl-head .name{font-size:15px;font-weight:700;color:#f1f4f8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ctrl-head .sub{font-family:var(--mono);font-size:11.5px;color:var(--faint);margin-top:1px}
.ctrl-head .close{background:none;border:none;color:var(--faint);font-size:22px;cursor:pointer;padding:0 6px;line-height:1;border-radius:6px}
.ctrl-head .close:hover{color:#fff;background:#1d242e}
.ctrl-body{flex:1;overflow-y:auto;padding:12px;background:#0a0c10}
.ctrl-body .screen{position:relative;border-radius:10px;overflow:hidden;background:#000;cursor:crosshair;user-select:none;touch-action:none}
.ctrl-body .screen img{width:100%;display:block;max-height:62vh;object-fit:contain;background:#000}
.ctrl-body .screen .loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);color:var(--muted);font-size:12px;border-radius:10px}
.ctrl-tools{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px 12px;border-top:1px solid var(--ink-line);background:#11151c}
.ctrl-tools .btn{background:#1a2029;border:1px solid var(--card-line);color:var(--muted);border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer;transition:all .12s;font-family:var(--body)}
.ctrl-tools .btn:hover{color:var(--teal);border-color:var(--teal)}
.ctrl-tools .btn.wake{color:var(--green);border-color:rgba(52,211,153,.4)}
.ctrl-tools .btn.wake:hover{border-color:var(--green)}
.ctrl-tools .btn.sleep{color:var(--faint)}
.ctrl-tools .text-row{display:flex;gap:6px;flex:1 0 100%;margin-top:2px}
.ctrl-tools .text-row input{flex:1;background:#0d1117;border:1px solid var(--card-line);border-radius:8px;padding:6px 10px;font-size:12px;color:var(--text);outline:none;font-family:var(--body)}
.ctrl-tools .text-row input:focus{border-color:var(--teal)}
/* 滚动条 */
::-webkit-scrollbar{width:8px}
::-webkit-scrollbar-thumb{background:#232936;border-radius:4px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<header>
<div class="title">设备监控大屏</div>
<div class="stats" id="wall-stats"></div>
<label class="wall-btn" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="wall-filter-online" checked onchange="refreshStatus()"> 只看在线
</label>
<button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button>
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
</header>
<main id="wall-grid"></main>
<!-- 设备操作弹窗(看屏 + 触控) -->
<div class="ctrl-overlay" id="ctrl-overlay">
<div class="ctrl-box">
<div class="ctrl-head">
<div class="info">
<div class="name" id="ctrl-name">设备</div>
<div class="sub" id="ctrl-sub">--</div>
</div>
<button class="close" onclick="closeCtrl()">×</button>
</div>
<div class="ctrl-body">
<div class="screen" id="ctrl-screen">
<img id="ctrl-img" alt="">
<div class="loading" id="ctrl-loading">连接中(首次需推送 atx-agent,最长约 30 秒)...</div>
</div>
</div>
<div class="ctrl-tools">
<button class="btn wake" onclick="ctrlWake()">💡 亮屏</button>
<button class="btn sleep" onclick="ctrlSleep()">🌙 息屏</button>
<button class="btn" onclick="ctrlKey('back')">◀ 返回</button>
<button class="btn" onclick="ctrlKey('home')">🏠 主页</button>
<button class="btn" onclick="ctrlKey('recent')">📋 最近</button>
<button class="btn" onclick="ctrlKey('menu')">☰ 菜单</button>
<button class="btn" onclick="ctrlKey('power')">⏻ 电源</button>
<div class="text-row">
<input type="text" id="ctrl-text" placeholder="输入文字(需光标在输入框)" onkeydown="if(event.key==='Enter')ctrlSendText()">
<button class="btn" onclick="ctrlSendText()">发送</button>
</div>
</div>
</div>
</div>
<script>
// ================== 监控大屏(深色控制室) ==================
// 状态每 5s 刷新;缩略图每 2.5s 轮询(页面不可见时暂停)
// 缩略图走 fetch 读取 X-Screen-State 头,卡片显示 亮屏中/熄屏中
const STATUS_MAP={idle:'空闲',connecting:'连接中',running:'运行中',done:'完成',error:'异常',released:'已释放',failed:'失败'};
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统'};
let _devices=[];
let _ctrlSerial=''; // 操作弹窗当前设备
let _ctrlTimer=null; // 弹窗状态刷新定时器
let _screenState={}; // serial -> on/off(来自缩略图响应头)
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// CSRF:独立页面需先取 token,POST 必须携带(与主后台 base.js 一致)
let _csrfToken='';
async function initCsrf(){
try{const r=await fetch('/api/csrf');const d=await r.json();_csrfToken=d.token||'';}catch(e){}
}
async function apiGet(url){
try{
const r=await fetch(url);
if(r.status===401){window.location='/login';return null;}
return await r.json();
}catch(e){return null;}
}
async function apiPost(url,body){
try{
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':_csrfToken},body:JSON.stringify(body||{})});
if(r.status===401){window.location='/login';return null;}
return await r.json();
}catch(e){return null;}
}
// 时钟
function tickClock(){
const now=new Date();
document.getElementById('wall-clock').textContent=
String(now.getHours()).padStart(2,'0')+':'+String(now.getMinutes()).padStart(2,'0')+':'+String(now.getSeconds()).padStart(2,'0');
document.getElementById('wall-date').textContent=
now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0')+' '+['周日','周一','周二','周三','周四','周五','周六'][now.getDay()];
}
// 状态刷新(5s)
async function refreshStatus(){
const r=await apiGet('/api/status');
if(!r||!r.ok)return;
const devs=(r.devices||[]).sort((a,b)=>a.serial.localeCompare(b.serial));
const changed=JSON.stringify(devs.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model,d.battery]))!==
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model,d.battery]));
_devices=devs;
renderStats(devs);
if(changed)renderGrid(visibleDevs(devs));
// 操作弹窗打开时同步刷新其标题状态
if(_ctrlSerial)updateCtrlHead();
}
// 「只看在线」勾选时过滤离线设备(默认勾选)
function visibleDevs(devs){
const onlyOnline=document.getElementById('wall-filter-online')
? document.getElementById('wall-filter-online').checked : true;
return onlyOnline? devs.filter(d=>d.present) : devs;
}
function renderStats(devs){
const online=devs.filter(d=>d.present).length;
const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length;
const error=devs.filter(d=>['error','failed'].includes(d.worker_status)).length;
document.getElementById('wall-stats').innerHTML=
'<div class="stat"><div class="n">'+devs.length+'</div><div class="l">设备总数</div></div>'+
'<div class="stat online"><div class="n">'+online+'</div><div class="l">在线</div></div>'+
'<div class="stat running"><div class="n">'+running+'</div><div class="l">运行中</div></div>'+
'<div class="stat error"><div class="n">'+error+'</div><div class="l">异常</div></div>';
}
function cardCls(d){
if(['error','failed'].includes(d.worker_status))return 'error';
if(['running','connecting'].includes(d.worker_status))return 'running';
return d.present?'online':'offline';
}
// 状态角标文案:运行中/异常优先,其次熄屏/亮屏状态
function badgeText(d){
if(['running','connecting'].includes(d.worker_status))return STATUS_MAP[d.worker_status]||d.worker_status;
if(['error','failed'].includes(d.worker_status))return STATUS_MAP[d.worker_status]||d.worker_status;
if(!d.present)return '离线';
return _screenState[d.serial]==='off'?'熄屏中':'亮屏中';
}
function fmtProgress(p){
if(!p||!p.total)return '';
const pct=Math.min(100,Math.round((p.done||0)/p.total*100));
return '<div class="prog"><i style="width:'+pct+'%"></i></div>'+
'<div class="prog-txt">'+(p.done||0)+'/'+p.total+(p.unit?' '+esc(p.unit):'')+' · '+pct+'%</div>';
}
function shortIp(serial){return String(serial||'').replace(/:5555$/,'');}
// 电量:告警档位(tier 0 正常/1 低/2 严重)由后端算好,前端只上色——
// 阈值只在「工具 → 设备发现 → 电量监控」一处定义,前端不重复判,免得两边不一致
function battHtml(d){
const b=d.battery;
if(!b)return '<span class="batt">🔋<span class="lv">—</span></span>';
const t=b.tier||0;
const cls=t===2?'crit':(t===1?'low':'ok');
const chg=b.charging?'<span class="chg" title="充电中">⚡</span>':'';
const mins=b.at?Math.floor((Date.now()/1000-b.at)/60):-1;
const minsTxt=(mins<0?'未知':(mins<1?'刚刚':mins+' 分钟前'));
const tip=(t===2?'电量严重不足':(t===1?'电量低':'电量正常'))
+'('+minsTxt+'采集'+(d.present?'':',设备已离线,显示最后一次读数')+')';
return '<span class="batt '+cls+(d.present?'':' stale')+'" title="'+tip+'">🔋'
+'<span class="lv">'+b.level+'%</span>'+chg+'</span>';
}
function cardHtml(d, i){
const cls=cardCls(d);
const serial=esc(d.serial);
const name=esc(d.device_name||d.model||'设备');
const ip=esc(shortIp(d.serial));
const st=badgeText(d);
const stTag=_screenState[d.serial]==='off'?'<div class="screen-tag off">🌙</div>':'';
const action=d.current_action
?'<div class="action">'+(d.task_job?'<b>'+esc(d.task_job)+'</b> · ':'')+esc(d.current_action)+'</div>'
:(d.task_job?'<div class="action"><b>'+esc(d.task_job)+'</b></div>':'<div class="action"></div>');
return '<div class="card '+cls+'" data-serial="'+serial+'" style="animation-delay:'+(i*30)+'ms" onclick="openCtrl(\''+serial+'\')">'+
'<div class="thumb">'+
'<img data-thumb="'+serial+'" alt="">'+
'<div class="ph"><span class="ico">📱</span><span>'+serial+'</span></div>'+
'<div class="badge"><span class="dot"></span>'+st+'</div>'+stTag+
'</div>'+
'<div class="info">'+
'<div class="name">'+name+'</div>'+
'<div class="ip">'+ip+'</div>'+
'<div class="meta"><span class="model">'+esc(d.model||'')+'</span>'
+'<span class="batt-wrap">'+battHtml(d)+'</span></div>'+
action+fmtProgress(d.progress)+
'<div class="hint">点击进入设备操作</div>'+
'</div>'+
'</div>';
}
// 增量渲染:已有卡片只更新信息区(不重建 img,缩略图不闪不重载);增删设备才动 DOM
function renderGrid(devs){
const grid=document.getElementById('wall-grid');
if(!devs.length){
grid.innerHTML='<div class="empty-wall">设备池为空,请到「工具 → 设备池管理」添加设备</div>';
return;
}
const existing={};
grid.querySelectorAll('.card').forEach(c=>{existing[c.dataset.serial]=c;});
// 1. 移除已不存在的卡片
Object.keys(existing).forEach(s=>{
if(!devs.find(d=>d.serial===s))existing[s].remove();
});
// 2. 更新/新建
devs.forEach((d,i)=>{
const serial=d.serial;
const card=existing[serial];
if(card){
// 已有卡片:只更新状态类信息(badge/class/信息区),不动 img
card.className='card '+cardCls(d);
const badge=card.querySelector('.badge');
const bt=badgeText(d);
if(badge&&badge.textContent!==bt)badge.innerHTML='<span class="dot"></span>'+bt;
// 熄屏角标
const tag=card.querySelector('.screen-tag');
if(_screenState[serial]==='off'&&!tag)card.querySelector('.thumb').insertAdjacentHTML('beforeend','<div class="screen-tag off">🌙</div>');
else if(_screenState[serial]!=='off'&&tag)tag.remove();
// 信息区
card.querySelector('.name').textContent=d.device_name||d.model||'设备';
card.querySelector('.ip').textContent=shortIp(serial);
card.querySelector('.model').textContent=d.model||'';
// 电量(与型号同一行,见 .meta)
const bw=card.querySelector('.batt-wrap');
if(bw)bw.innerHTML=battHtml(d);
const action=d.current_action
?(d.task_job?'<b>'+esc(d.task_job)+'</b> · ':'')+esc(d.current_action)
:(d.task_job?'<b>'+esc(d.task_job)+'</b>':'');
card.querySelector('.action').innerHTML=action;
// 进度
const progWrap=card.querySelector('.prog-wrap');
if(progWrap)progWrap.innerHTML=fmtProgress(d.progress);
else if(d.progress&&d.progress.total)card.querySelector('.info').insertAdjacentHTML('beforeend','<div class="prog-wrap">'+fmtProgress(d.progress)+'</div>');
// 离线占位切换
const img=card.querySelector('img[data-thumb]');
const ph=card.querySelector('.ph');
if(d.present){if(img)img.style.display='block';if(ph)ph.style.display='none';}
else{if(img)img.style.display='none';if(ph)ph.style.display='flex';}
}else{
grid.insertAdjacentHTML('beforeend',cardHtml(d,i));
const nc=grid.lastElementChild;
if(!d.present)nc.querySelector('.ph').style.display='flex';
}
});
refreshThumbs();
}
// 缩略图轮询(2.5s):fetch 读取 X-Screen-State 头 → blob 喂 img
async function refreshThumbs(){
// 进入设备操作弹窗后:停止全部缩略图获取,资源全部给当前设备的高清流
if(_ctrlSerial)return;
if(document.visibilityState!=='visible')return;
const now=Date.now();
const imgs=[...document.querySelectorAll('img[data-thumb]')];
await Promise.all(imgs.map(async img=>{
const serial=img.dataset.thumb;
try{
const r=await fetch('/api/screen/thumb?serial='+encodeURIComponent(serial)+'&t='+now);
if(!r.ok)return;
_screenState[serial]=r.headers.get('X-Screen-State')||'unknown';
const blob=await r.blob();
if(blob.size>0)img.src=URL.createObjectURL(blob);
// 状态角标变化时更新(熄屏中/亮屏中)
const card=img.closest('.card');
if(card){
const d=_devices.find(x=>x.serial===serial);
if(d&&d.present&&!(['running','connecting','error','failed'].includes(d.worker_status))){
const badge=card.querySelector('.badge');
const txt=badgeText(d);
if(badge&&badge.textContent!==txt)badge.innerHTML='<span class="dot"></span>'+txt;
const tag=card.querySelector('.screen-tag');
if(_screenState[serial]==='off'&&!tag)card.querySelector('.thumb').insertAdjacentHTML('beforeend','<div class="screen-tag off">🌙</div>');
else if(_screenState[serial]!=='off'&&tag)tag.remove();
}
}
}catch(e){}
}));
}
// ================== 一键亮屏 ==================
async function wakeAll(){
const btn=document.getElementById('btn-wake-all');
btn.disabled=true;btn.textContent='亮屏中...';
const r=await apiPost('/api/device/screen_all',{mode:'on'});
btn.disabled=false;btn.textContent='💡 一键亮屏';
if(r&&r.ok){refreshStatus();setTimeout(refreshThumbs,800);}
}
// ================== 设备操作弹窗(看屏 + 触控) ==================
function openCtrl(serial){
const d=_devices.find(x=>x.serial===serial);
if(!d)return;
_ctrlSerial=serial;
document.getElementById('ctrl-name').textContent=d.device_name||d.model||shortIp(serial);
document.getElementById('ctrl-sub').textContent=serial;
document.getElementById('ctrl-overlay').classList.add('show');
const img=document.getElementById('ctrl-img');
img.src='';
document.getElementById('ctrl-loading').style.display='flex';
img.onload=()=>{document.getElementById('ctrl-loading').style.display='none';};
img.onerror=()=>{document.getElementById('ctrl-loading').style.display='none';};
startCtrlStream();
}
function closeCtrl(){
_ctrlSerial='';
document.getElementById('ctrl-overlay').classList.remove('show');
document.getElementById('ctrl-img').src='';
}
function startCtrlStream(){
if(!_ctrlSerial)return;
const img=document.getElementById('ctrl-img');
img.src='/api/screen/stream?serial='+encodeURIComponent(_ctrlSerial)+'&q=85&fps=10&t='+Date.now(); // 弹窗高清高帧
}
function updateCtrlHead(){
const d=_devices.find(x=>x.serial===_ctrlSerial);
if(!d)return;
document.getElementById('ctrl-name').textContent=d.device_name||d.model||shortIp(_ctrlSerial);
document.getElementById('ctrl-sub').textContent=_ctrlSerial+(d.present?'':'(离线)');
}
// 坐标映射:显示尺寸 -> 设备原生分辨率
// 图片 object-fit:contain 时实际绘制区域小于盒子(居中留黑边),
// 必须按绘制区域换算,否则黑边/偏移导致点击错位
function ctrlCoords(ev){
const img=document.getElementById('ctrl-img');
if(!img.naturalWidth||!img.naturalHeight)return[null,null];
const r=img.getBoundingClientRect();
const scale=Math.min(r.width/img.naturalWidth,r.height/img.naturalHeight);
const drawW=img.naturalWidth*scale, drawH=img.naturalHeight*scale;
const ox=(r.width-drawW)/2, oy=(r.height-drawH)/2; // contain 偏移
const px=ev.clientX-r.left, py=ev.clientY-r.top;
if(px<ox||px>ox+drawW||py<oy||py>oy+drawH)return[null,null]; // 黑边点击忽略
return[Math.round((px-ox)/scale),Math.round((py-oy)/scale)];
}
function ctrlTap(x,y){
if(x==null||!x)return;
apiPost('/api/screen/tap',{serial:_ctrlSerial,x,y});
}
function ctrlSwipe(x1,y1,x2,y2){
if(x1==null)return;
apiPost('/api/screen/swipe',{serial:_ctrlSerial,x1,y1,x2,y2});
}
function ctrlKey(key){
if(!_ctrlSerial)return;
apiPost('/api/screen/key',{serial:_ctrlSerial,key});
}
function ctrlWake(){if(_ctrlSerial)apiPost('/api/device/screen_all',{mode:'on',serials:[_ctrlSerial]});}
function ctrlSleep(){if(_ctrlSerial)apiPost('/api/device/screen_all',{mode:'off',serials:[_ctrlSerial]});}
function ctrlSendText(){
const inp=document.getElementById('ctrl-text');
const text=(inp.value||'').trim();
if(!_ctrlSerial||!text)return;
apiPost('/api/screen/text',{serial:_ctrlSerial,text}).then(r=>{if(r&&r.ok)inp.value='';});
}
// 鼠标/触控:拖拽=滑动,点按=轻触
(function(){
const screen=document.getElementById('ctrl-screen');
let dragging=false,sx=0,sy=0;
screen.addEventListener('mousedown',ev=>{
if(!_ctrlSerial)return;
ev.preventDefault();dragging=true;
const[x,y]=ctrlCoords(ev);sx=x;sy=y;
});
screen.addEventListener('mousemove',ev=>{if(dragging)ev.preventDefault();});
window.addEventListener('mouseup',ev=>{
if(!dragging)return;dragging=false;
const[x,y]=ctrlCoords(ev);
if(x==null)return;
if(Math.abs(x-sx)<8&&Math.abs(y-sy)<8)ctrlTap(x,y);
else ctrlSwipe(sx,sy,x,y);
});
screen.addEventListener('touchstart',ev=>{
if(!_ctrlSerial)return;
ev.preventDefault();const t=ev.touches[0];dragging=true;
const[x,y]=ctrlCoords(t);sx=x;sy=y;
},{passive:false});
screen.addEventListener('touchend',ev=>{
if(!dragging)return;dragging=false;
const t=ev.changedTouches[0];
const[x,y]=ctrlCoords(t);
if(x==null)return;
if(Math.abs(x-sx)<8&&Math.abs(y-sy)<8)ctrlTap(x,y);
else ctrlSwipe(sx,sy,x,y);
},{passive:false});
})();
// 定时器
setInterval(()=>{if(document.visibilityState==='visible')refreshThumbs();},5000); // 网格低帧率(省资源)
setInterval(()=>{if(document.visibilityState==='visible')refreshStatus();},5000);
// 初始化
initCsrf();tickClock();setInterval(tickClock,1000);
refreshStatus();
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==='visible'){refreshStatus();refreshThumbs();}
});
</script>
</body>
</html>