feat: 大屏增强——一键亮屏按钮、卡片显示设备名+IP+熄屏状态(thumb 响应头 X-Screen-State)、点击卡片进入设备操作弹窗(实时流+触控+按键+文字),工具页远程看屏子分栏移除并入大屏
This commit is contained in:
@@ -112,7 +112,6 @@ function showSubTab(tabId, name){
|
||||
const tab = document.getElementById('tab-'+tabId);
|
||||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
||||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||||
if(name==='screen' && typeof loadScreenDevices==='function') loadScreenDevices();
|
||||
if(name==='devpool' && typeof loadDevPool==='function') loadDevPool();
|
||||
}
|
||||
|
||||
|
||||
@@ -277,127 +277,6 @@ function copyTsKey(){
|
||||
}
|
||||
|
||||
|
||||
// ================== Tab: 工具 - 远程看屏(MJPEG 流 + u2 触控) ==================
|
||||
let _screenViewOn = false;
|
||||
|
||||
async function loadScreenDevices(force){
|
||||
const sel=document.getElementById('screen-device');
|
||||
if(!sel)return;
|
||||
const list=await _fetchAdbDevices(force);
|
||||
if(list===null)return;
|
||||
const cur=sel.value;
|
||||
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)'};
|
||||
sel.innerHTML=list.map(d=>
|
||||
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
|
||||
).join('');
|
||||
if(cur)sel.value=cur;
|
||||
}
|
||||
|
||||
function _screenSerial(){
|
||||
const sel=document.getElementById('screen-device');
|
||||
return sel?sel.value:'';
|
||||
}
|
||||
|
||||
function startScreenView(){
|
||||
const serial=_screenSerial();
|
||||
if(!serial){showToast('请先选择设备','error');return;}
|
||||
stopScreenView();
|
||||
const img=document.getElementById('screen-img');
|
||||
const loading=document.getElementById('screen-loading');
|
||||
loading.style.display='flex';
|
||||
document.getElementById('screen-status').textContent='';
|
||||
img.src='/api/screen/stream?serial='+encodeURIComponent(serial);
|
||||
img.onload=()=>{loading.style.display='none';_screenViewOn=true;document.getElementById('screen-status').textContent='观看中';};
|
||||
img.onerror=()=>{loading.style.display='none';_screenViewOn=false;showToast('画面流中断(设备离线或已停止)','error');};
|
||||
}
|
||||
|
||||
function stopScreenView(){
|
||||
const img=document.getElementById('screen-img');
|
||||
if(img)img.src='';
|
||||
_screenViewOn=false;
|
||||
const loading=document.getElementById('screen-loading');
|
||||
if(loading)loading.style.display='none';
|
||||
const st=document.getElementById('screen-status');
|
||||
if(st)st.textContent='';
|
||||
}
|
||||
|
||||
// 显示尺寸 -> 设备原生分辨率(img.naturalWidth/Height 即设备分辨率)
|
||||
function _screenCoords(ev){
|
||||
const img=document.getElementById('screen-img');
|
||||
if(!img.naturalWidth)return [null,null];
|
||||
const r=img.getBoundingClientRect();
|
||||
return [
|
||||
Math.round((ev.clientX-r.left)/r.width*img.naturalWidth),
|
||||
Math.round((ev.clientY-r.top)/r.height*img.naturalHeight),
|
||||
];
|
||||
}
|
||||
|
||||
function _screenTap(x,y){
|
||||
const serial=_screenSerial();
|
||||
if(!serial||x==null||y==null)return;
|
||||
apiPost('/api/screen/tap',{serial,x,y}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');});
|
||||
}
|
||||
|
||||
function _screenSwipe(x1,y1,x2,y2){
|
||||
const serial=_screenSerial();
|
||||
if(!serial||x1==null)return;
|
||||
apiPost('/api/screen/swipe',{serial,x1,y1,x2,y2}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');});
|
||||
}
|
||||
|
||||
function screenKey(key){
|
||||
const serial=_screenSerial();
|
||||
if(!serial){showToast('请先选择设备','error');return;}
|
||||
apiPost('/api/screen/key',{serial,key}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');});
|
||||
}
|
||||
|
||||
function screenSendText(){
|
||||
const serial=_screenSerial();
|
||||
const inp=document.getElementById('screen-text');
|
||||
const text=(inp.value||'').trim();
|
||||
if(!serial){showToast('请先选择设备','error');return;}
|
||||
if(!text){showToast('请输入文字','error');return;}
|
||||
apiPost('/api/screen/text',{serial,text}).then(r=>{
|
||||
if(r&&!r.ok)showToast(r.error,'error');
|
||||
else inp.value='';
|
||||
});
|
||||
}
|
||||
|
||||
// 鼠标 + 触控:拖拽=滑动,点按=轻触
|
||||
(function(){
|
||||
const img=document.getElementById('screen-img');
|
||||
if(!img)return;
|
||||
let dragging=false, sx=0, sy=0;
|
||||
|
||||
function onDown(ev){
|
||||
if(!_screenViewOn)return;
|
||||
ev.preventDefault();
|
||||
dragging=true;
|
||||
const [x,y]=_screenCoords(ev);
|
||||
sx=x; sy=y;
|
||||
}
|
||||
function onUp(ev){
|
||||
if(!dragging)return;
|
||||
dragging=false;
|
||||
const [x,y]=_screenCoords(ev);
|
||||
if(x==null)return;
|
||||
if(Math.abs(x-sx)<8 && Math.abs(y-sy)<8){
|
||||
_screenTap(x,y); // 位移小 → 点击
|
||||
}else{
|
||||
_screenSwipe(sx,sy,x,y); // 有位移 → 滑动
|
||||
}
|
||||
}
|
||||
|
||||
img.addEventListener('mousedown',onDown);
|
||||
img.addEventListener('mousemove',ev=>{if(dragging)ev.preventDefault();});
|
||||
window.addEventListener('mouseup',onUp);
|
||||
img.addEventListener('touchstart',onDown,{passive:false});
|
||||
img.addEventListener('touchend',ev=>{
|
||||
if(!dragging)return;
|
||||
const t=ev.changedTouches[0];
|
||||
onUp({clientX:t.clientX,clientY:t.clientY,preventDefault(){}});
|
||||
},{passive:false});
|
||||
})();
|
||||
|
||||
// ================== Tab: 工具 - 设备池管理(本地 SQLite 清单) ==================
|
||||
async function loadDevPool(){
|
||||
const tb=document.getElementById('tb-devpool');
|
||||
|
||||
@@ -494,7 +494,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
|
||||
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
|
||||
<button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
|
||||
<button class="sub-tab" data-sub="screen" onclick="showSubTab('tools','screen')">远程看屏</button>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 1:剪贴板注入 -->
|
||||
@@ -674,41 +673,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 7:远程看屏(MJPEG 实时流 + 触控) -->
|
||||
<div class="sub-panel" id="tools-sub-screen">
|
||||
<div class="toolbar">
|
||||
<select id="screen-device" class="form-control" style="width:300px;font-family:monospace"></select>
|
||||
<button class="btn btn-primary" onclick="startScreenView()">开始看屏</button>
|
||||
<button class="btn" onclick="stopScreenView()">停止</button>
|
||||
<button class="btn" onclick="loadScreenDevices(true)">刷新设备</button>
|
||||
<span id="screen-status" class="text-muted"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div style="position:relative" id="screen-stage">
|
||||
<img id="screen-img" alt="远程画面"
|
||||
style="max-width:420px;border-radius:12px;border:1px solid var(--border);background:#000;cursor:crosshair;user-select:none">
|
||||
<div id="screen-loading" class="empty" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6);color:#fff;border-radius:12px">连接中(首次连接需推送 atx-agent,最长约 30 秒)...</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px">
|
||||
<div style="font-size:12px;color:var(--text-light)">操作(鼠标拖拽=滑动,点击=轻触)</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<button class="btn btn-xs" onclick="screenKey('back')">◀ 返回</button>
|
||||
<button class="btn btn-xs" onclick="screenKey('home')">🏠 主页</button>
|
||||
<button class="btn btn-xs" onclick="screenKey('recent')">📋 最近</button>
|
||||
<button class="btn btn-xs" onclick="screenKey('menu')">☰ 菜单</button>
|
||||
<button class="btn btn-xs" onclick="screenKey('power')">⏻ 电源</button>
|
||||
<button class="btn btn-xs" onclick="screenKey('volume_up')">🔊 音量+</button>
|
||||
<button class="btn btn-xs" onclick="screenKey('volume_down')">🔉 音量-</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:4px">
|
||||
<input type="text" id="screen-text" class="form-control" style="flex:1" placeholder="输入文字(需光标在输入框)"
|
||||
onkeydown="if(event.key==='Enter')screenSendText()">
|
||||
<button class="btn btn-xs" onclick="screenSendText()">发送</button>
|
||||
</div>
|
||||
<div class="help" style="margin-top:6px">画面为设备原生分辨率 JPEG 流(约 3-6 帧/秒)。设备息屏时画面为黑,可先点电源/亮屏。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content -->
|
||||
|
||||
+249
-40
@@ -21,62 +21,91 @@
|
||||
*{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:28px;padding:16px 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:22px;font-weight:800;letter-spacing:1px;color:#fff;display:flex;align-items:center;gap:12px}
|
||||
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:7px 18px;text-align:center;min-width:86px}
|
||||
header .stat .n{font-family:var(--mono);font-size:21px;font-weight:600;line-height:1.15}
|
||||
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 .clock{font-family:var(--mono);font-size:19px;color:var(--muted);text-align:right;line-height:1.2}
|
||||
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 - 86px);overflow-y:auto;padding:18px 24px 30px;display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:16px;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;transition:border-color .2s,box-shadow .2s}
|
||||
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.online{border-color:#263142}
|
||||
.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:flex;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{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,.78);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 .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.online .badge .dot{background:var(--green);box-shadow:0 0 6px rgba(52,211,153,.7)}
|
||||
.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:10px 12px 12px}
|
||||
.card .info .serial{font-family:var(--mono);font-size:13px;font-weight:600;color:#eef2f7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .info .model{font-size:11px;color:var(--faint);margin-top:2px}
|
||||
.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 .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 .fg{margin-top:4px}
|
||||
.card .info .fg .tag{font-size:10.5px;background:#1c2430;color:#9fb3c8;border:1px solid var(--ink-line);border-radius:5px;padding:1px 7px;display:inline-block}
|
||||
/* 进度条 */
|
||||
.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}
|
||||
@@ -87,19 +116,60 @@ main{position:relative;z-index:2;height:calc(100vh - 86px);overflow-y:auto;paddi
|
||||
<header>
|
||||
<div class="title">设备监控大屏</div>
|
||||
<div class="stats" id="wall-stats"></div>
|
||||
<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 轮询(页面不可见时暂停,省资源)
|
||||
// 状态每 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
@@ -107,6 +177,13 @@ async function apiGet(url){
|
||||
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(){
|
||||
@@ -121,13 +198,14 @@ function tickClock(){
|
||||
async function refreshStatus(){
|
||||
const r=await apiGet('/api/status');
|
||||
if(!r||!r.ok)return;
|
||||
const devs=r.devices||[];
|
||||
// 保持卡片顺序稳定:按 serial 排序
|
||||
devs.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.foreground_app]))!==JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.foreground_app]));
|
||||
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]))!==
|
||||
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]));
|
||||
_devices=devs;
|
||||
renderStats(devs);
|
||||
if(changed)renderGrid(devs);
|
||||
// 操作弹窗打开时同步刷新其标题状态
|
||||
if(_ctrlSerial)updateCtrlHead();
|
||||
}
|
||||
|
||||
function renderStats(devs){
|
||||
@@ -146,10 +224,12 @@ function cardCls(d){
|
||||
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;
|
||||
return d.present?'在线':'离线';
|
||||
if(!d.present)return '离线';
|
||||
return _screenState[d.serial]==='off'?'熄屏中':'亮屏中';
|
||||
}
|
||||
function fmtProgress(p){
|
||||
if(!p||!p.total)return '';
|
||||
@@ -157,6 +237,7 @@ function fmtProgress(p){
|
||||
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$/,'');}
|
||||
|
||||
function renderGrid(devs){
|
||||
const grid=document.getElementById('wall-grid');
|
||||
@@ -167,21 +248,25 @@ function renderGrid(devs){
|
||||
grid.innerHTML=devs.map((d,i)=>{
|
||||
const cls=cardCls(d);
|
||||
const serial=esc(d.serial);
|
||||
const fg=d.foreground_app&&d.foreground_app!=='空闲'&&!String(d.foreground_app).startsWith('(')
|
||||
?'<div class="fg"><span class="tag">'+(APP_NAMES[d.foreground_app]?esc(APP_NAMES[d.foreground_app]):esc(d.foreground_app))+'</span></div>':'';
|
||||
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*35)+'ms">'+
|
||||
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" style="display:none"><span class="ico">📱</span><span>'+serial+'</span></div>'+
|
||||
'<div class="badge"><span class="dot"></span>'+badgeText(d)+'</div>'+
|
||||
'<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="serial">'+serial+'</div>'+
|
||||
'<div class="name">'+name+'</div>'+
|
||||
'<div class="ip">'+ip+'</div>'+
|
||||
'<div class="model">'+esc(d.model||'')+'</div>'+
|
||||
action+fg+fmtProgress(d.progress)+
|
||||
action+fmtProgress(d.progress)+
|
||||
'<div class="hint">点击进入设备操作</div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -196,20 +281,144 @@ function renderGrid(devs){
|
||||
refreshThumbs();
|
||||
}
|
||||
|
||||
// 缩略图轮询(2.5s,页面不可见时暂停)
|
||||
let _thumbTimer=null;
|
||||
function refreshThumbs(){
|
||||
// 缩略图轮询(2.5s):fetch 读取 X-Screen-State 头 → blob 喂 img
|
||||
async function refreshThumbs(){
|
||||
if(document.visibilityState!=='visible')return;
|
||||
const now=Date.now();
|
||||
document.querySelectorAll('img[data-thumb]').forEach(img=>{
|
||||
img.src='/api/screen/thumb?serial='+encodeURIComponent(img.dataset.thumb)+'&t='+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)+'&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?'':'(离线)');
|
||||
}
|
||||
// 坐标映射:显示尺寸 -> 设备原生分辨率
|
||||
function ctrlCoords(ev){
|
||||
const img=document.getElementById('ctrl-img');
|
||||
if(!img.naturalWidth)return[null,null];
|
||||
const r=img.getBoundingClientRect();
|
||||
return[
|
||||
Math.round((ev.clientX-r.left)/r.width*img.naturalWidth),
|
||||
Math.round((ev.clientY-r.top)/r.height*img.naturalHeight),
|
||||
];
|
||||
}
|
||||
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();},2500);
|
||||
setInterval(()=>{if(document.visibilityState==='visible')refreshStatus();},5000);
|
||||
|
||||
// 初始化
|
||||
tickClock();setInterval(tickClock,1000);
|
||||
initCsrf();tickClock();setInterval(tickClock,1000);
|
||||
refreshStatus();
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if(document.visibilityState==='visible'){refreshStatus();refreshThumbs();}
|
||||
|
||||
+20
-1
@@ -1042,6 +1042,7 @@ def api_screen_thumb():
|
||||
|
||||
一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。
|
||||
大屏按需轮询:每设备约 2.5s 一帧,20 台 ≈ 0.2 核 CPU + 100KB/s。
|
||||
响应头 X-Screen-State: on/off/unknown —— 大屏据此显示「亮屏中/熄屏中」。
|
||||
"""
|
||||
serial = request.args.get("serial", "")
|
||||
if not serial:
|
||||
@@ -1059,10 +1060,28 @@ def api_screen_thumb():
|
||||
buf = io.BytesIO()
|
||||
img.convert("RGB").save(buf, "JPEG", quality=55)
|
||||
return Response(buf.getvalue(), mimetype="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
headers={"Cache-Control": "no-store",
|
||||
"X-Screen-State": _device_screen_state(serial)})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 503
|
||||
|
||||
|
||||
def _device_screen_state(serial):
|
||||
"""设备屏幕状态:on/off/unknown(dumpsys display 只读查询)。
|
||||
|
||||
USB 设备(远程 adb server)查询失败返回 unknown,不影响缩略图。
|
||||
"""
|
||||
try:
|
||||
if ":" not in serial:
|
||||
return "unknown"
|
||||
out = _adb("-s", serial, "shell", "dumpsys", "display") or ""
|
||||
m = re.search(r"m(?:Global)?ScreenState=(\w+)", out)
|
||||
if m:
|
||||
return "on" if m.group(1).upper() == "ON" else "off"
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
@app.route("/api/screen/tap", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_screen_tap():
|
||||
|
||||
Reference in New Issue
Block a user