feat: 新增设备画面截图、列表复选框/排序/批量停止,修复端口绑定失败
This commit is contained in:
@@ -27,7 +27,9 @@ else:
|
|||||||
|
|
||||||
# ================== Web 后台 ==================
|
# ================== Web 后台 ==================
|
||||||
WEB_HOST = "0.0.0.0"
|
WEB_HOST = "0.0.0.0"
|
||||||
WEB_PORT = 5050
|
# 端口必须避开 Windows 动态端口范围(本机被改成 1024-15000,默认 49152-65535)
|
||||||
|
# 用 18050 处于动态范围之外,不会被出站连接随机占用,避免 WinError 10013
|
||||||
|
WEB_PORT = 18050
|
||||||
|
|
||||||
# ================== 数据目录 ==================
|
# ================== 数据目录 ==================
|
||||||
# 持久化数据(分组、任务计划)存放目录
|
# 持久化数据(分组、任务计划)存放目录
|
||||||
|
|||||||
@@ -124,6 +124,35 @@ def adb_disconnect(remote_adb_url):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def screenshot(serial, timeout=5):
|
||||||
|
"""获取设备当前画面截图(PNG bytes)。
|
||||||
|
|
||||||
|
用 `adb exec-out -s <serial> shell screencap -p`,只读操作,
|
||||||
|
不抢占 u2 的 atx-agent 通道,任务运行中调用安全。
|
||||||
|
返回 (ok, png_bytes_or_errmsg)。
|
||||||
|
"""
|
||||||
|
if not serial:
|
||||||
|
return False, "缺少 serial"
|
||||||
|
# 先尝试连一下(任务运行中设备已 connect,这里是幂等的)
|
||||||
|
if not adb_connect_light(serial):
|
||||||
|
return False, "adb connect 失败"
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[ADB_PATH, "-s", serial, "exec-out", "screencap", "-p"],
|
||||||
|
capture_output=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
# PNG 文件头:\x89PNG
|
||||||
|
if r.stdout and r.stdout[:4] == b"\x89PNG":
|
||||||
|
return True, r.stdout
|
||||||
|
# 失败时 stdout 可能为空,看 stderr
|
||||||
|
err = (r.stderr or b"").decode("utf-8", errors="replace").strip()
|
||||||
|
return False, err or "截图失败(设备可能离线)"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "截图超时"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def identify_device(serial, duration=3):
|
def identify_device(serial, duration=3):
|
||||||
"""让设备响铃以供识别(直连 serial,不经过 STF)。
|
"""让设备响铃以供识别(直连 serial,不经过 STF)。
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -52,6 +52,6 @@ echo ============================================
|
|||||||
echo Fix complete! Now:
|
echo Fix complete! Now:
|
||||||
echo 1. Close ALL browser windows and reopen
|
echo 1. Close ALL browser windows and reopen
|
||||||
echo 2. Run: python web_server.py
|
echo 2. Run: python web_server.py
|
||||||
echo 3. Open: http://127.0.0.1:5050/
|
echo 3. Open: http://127.0.0.1:18050/
|
||||||
echo ============================================
|
echo ============================================
|
||||||
pause
|
pause
|
||||||
|
|||||||
+2
-2
@@ -23,8 +23,8 @@ if %errorlevel% neq 0 (
|
|||||||
|
|
||||||
echo ============================================
|
echo ============================================
|
||||||
echo 管理后台已启动(局域网可访问)
|
echo 管理后台已启动(局域网可访问)
|
||||||
echo 本机访问: http://127.0.0.1:5050/
|
echo 本机访问: http://127.0.0.1:18050/
|
||||||
echo 局域网访问: http://本机IP:5050/
|
echo 局域网访问: http://本机IP:18050/
|
||||||
echo 默认账号: admin / admin123
|
echo 默认账号: admin / admin123
|
||||||
echo 按 Ctrl+C 停止
|
echo 按 Ctrl+C 停止
|
||||||
echo ============================================
|
echo ============================================
|
||||||
|
|||||||
+255
-17
@@ -143,6 +143,27 @@ select.form-control{cursor:pointer}
|
|||||||
.toast.show{display:block}
|
.toast.show{display:block}
|
||||||
.toast-success{background:#dcfce7;color:#166534;border:1px solid #86efac}
|
.toast-success{background:#dcfce7;color:#166534;border:1px solid #86efac}
|
||||||
.toast-error{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}
|
.toast-error{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}
|
||||||
|
|
||||||
|
/* 截图弹窗 */
|
||||||
|
.shot-modal{max-width:none;width:min(440px,95vw)}
|
||||||
|
.shot-modal .modal-body{text-align:center;padding:14px}
|
||||||
|
.shot-img{max-width:100%;max-height:calc(100vh - 240px);width:auto;height:auto;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);background:#1e293b;min-height:200px;display:block;margin:0 auto}
|
||||||
|
.shot-loading{color:var(--text-light);padding:60px 0;font-size:13px}
|
||||||
|
.shot-err{color:var(--danger);padding:40px 0;font-size:13px;white-space:pre-wrap;word-break:break-all}
|
||||||
|
.shot-info{font-size:12px;color:var(--text-muted);margin-top:10px}
|
||||||
|
.shot-toolbar{display:flex;gap:8px;justify-content:center;align-items:center;margin-top:12px;flex-wrap:wrap}
|
||||||
|
.shot-toolbar .auto-label{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:4px}
|
||||||
|
.shot-toolbar .auto-label input{width:14px;height:14px}
|
||||||
|
|
||||||
|
/* 表头排序 */
|
||||||
|
.table thead th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
|
||||||
|
.table thead th.sortable:hover{background:#e2e8f0}
|
||||||
|
.table thead th.sortable::after{content:" ⇅";color:#cbd5e1;font-weight:400;font-size:11px}
|
||||||
|
.table thead th.sortable.sort-asc::after{content:" ↑";color:var(--primary);font-weight:700}
|
||||||
|
.table thead th.sortable.sort-desc::after{content:" ↓";color:var(--primary);font-weight:700}
|
||||||
|
.table tbody tr.row-checked td{background:#eff6ff}
|
||||||
|
.table .col-cb{width:36px;text-align:center}
|
||||||
|
.table .col-cb input{width:14px;height:14px;cursor:pointer;vertical-align:middle}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -176,15 +197,25 @@ select.form-control{cursor:pointer}
|
|||||||
<div class="section-title">设备列表</div>
|
<div class="section-title">设备列表</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')">停止全部</button>
|
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')">停止全部</button>
|
||||||
|
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备">停止选中</button>
|
||||||
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
|
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
|
||||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
|
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
|
||||||
<button class="btn" onclick="loadMonitor()">刷新</button>
|
<button class="btn" onclick="loadMonitor()">刷新</button>
|
||||||
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span></span>
|
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>设备</th><th>型号</th><th>STF</th><th>任务状态</th><th>前台App</th><th>进度</th>
|
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
|
||||||
<th>当前任务</th><th>当前动作</th><th>最近错误</th><th>操作</th>
|
<th class="sortable" data-key="serial" onclick="sortBy('serial')">设备</th>
|
||||||
|
<th class="sortable" data-key="model" onclick="sortBy('model')">型号</th>
|
||||||
|
<th class="sortable" data-key="stf_occupied" onclick="sortBy('stf_occupied')">STF</th>
|
||||||
|
<th class="sortable" data-key="worker_status" onclick="sortBy('worker_status')">任务状态</th>
|
||||||
|
<th class="sortable" data-key="foreground_app" onclick="sortBy('foreground_app')">前台App</th>
|
||||||
|
<th class="sortable" data-key="progress" onclick="sortBy('progress')">进度</th>
|
||||||
|
<th class="sortable" data-key="task_job" onclick="sortBy('task_job')">当前任务</th>
|
||||||
|
<th class="sortable" data-key="current_action" onclick="sortBy('current_action')">当前动作</th>
|
||||||
|
<th class="sortable" data-key="last_error" onclick="sortBy('last_error')">最近错误</th>
|
||||||
|
<th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-devices"></tbody>
|
<tbody id="tb-devices"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -289,7 +320,7 @@ select.form-control{cursor:pointer}
|
|||||||
|
|
||||||
<!-- ========== 模态框 ========== -->
|
<!-- ========== 模态框 ========== -->
|
||||||
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||||
<div class="modal">
|
<div class="modal" id="modal-box">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="modal-title">标题</h3>
|
<h3 id="modal-title">标题</h3>
|
||||||
<button class="close" onclick="closeModal()">×</button>
|
<button class="close" onclick="closeModal()">×</button>
|
||||||
@@ -326,12 +357,181 @@ async function apiPut(url,body){
|
|||||||
if(r.status===401){window.location='/login';return null;}
|
if(r.status===401){window.location='/login';return null;}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
// 识别设备:让设备响铃 3 秒
|
|
||||||
async function identifyDevice(serial){
|
// ================== 设备列表排序与多选 ==================
|
||||||
showToast('正在连接设备响铃...','');
|
let _sortKey=''; // 当前排序字段
|
||||||
const r=await apiPost('/api/device/identify',{serial:serial});
|
let _sortDir=''; // '' | 'asc' | 'desc'
|
||||||
if(r&&r.ok){showToast(r.msg||'已响铃','success');}
|
let _selectedSerials=new Set(); // 已勾选的 serial 集合
|
||||||
else{showToast(r?r.error:'响铃失败','error');}
|
|
||||||
|
// 点击表头排序
|
||||||
|
function sortBy(key){
|
||||||
|
if(_sortKey===key){
|
||||||
|
// 同一列:切换方向;已是降序则取消
|
||||||
|
if(_sortDir==='asc'){_sortDir='desc';}
|
||||||
|
else if(_sortDir==='desc'){_sortKey='';_sortDir='';}
|
||||||
|
else{_sortDir='asc';}
|
||||||
|
}else{
|
||||||
|
_sortKey=key;_sortDir='asc';
|
||||||
|
}
|
||||||
|
// 更新表头样式
|
||||||
|
document.querySelectorAll('.table thead th.sortable').forEach(th=>{
|
||||||
|
th.classList.remove('sort-asc','sort-desc');
|
||||||
|
if(th.dataset.key===_sortKey){
|
||||||
|
th.classList.add(_sortDir==='asc'?'sort-asc':(_sortDir==='desc'?'sort-desc':''));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序比较:从设备对象取排序值
|
||||||
|
function _sortValue(dev,key){
|
||||||
|
let v;
|
||||||
|
if(key==='progress'){
|
||||||
|
const p=dev.progress||{};
|
||||||
|
v=(p.done||0)+(((p.total||0)>0?(p.done||0)/(p.total):0)*10000);
|
||||||
|
}else if(key==='stf_occupied'){
|
||||||
|
v=dev.stf_occupied?1:0;
|
||||||
|
}else if(key==='worker_status'){
|
||||||
|
// 按状态优先级排序:running>connecting>idle>done>error>released
|
||||||
|
const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6};
|
||||||
|
v=order[dev.worker_status]||99;
|
||||||
|
}else{
|
||||||
|
v=dev[key]||'';
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
|
function toggleAllDevices(checked){
|
||||||
|
if(checked){
|
||||||
|
_statusData.devices.forEach(d=>_selectedSerials.add(d.serial));
|
||||||
|
}else{
|
||||||
|
_selectedSerials.clear();
|
||||||
|
}
|
||||||
|
renderDevices();
|
||||||
|
updateSelCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个勾选
|
||||||
|
function toggleDevice(serial,checked){
|
||||||
|
if(checked){_selectedSerials.add(serial);}else{_selectedSerials.delete(serial);}
|
||||||
|
updateSelCount();
|
||||||
|
// 同步全选框状态
|
||||||
|
const all=document.getElementById('cb-all');
|
||||||
|
if(all){
|
||||||
|
const total=_statusData?(_statusData.devices||[]).length:0;
|
||||||
|
all.checked=total>0&&_selectedSerials.size===total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelCount(){
|
||||||
|
const n=_selectedSerials.size;
|
||||||
|
const el=document.getElementById('sel-count');
|
||||||
|
if(el)el.textContent=n>0?('已选 '+n+' 台'):'未选中';
|
||||||
|
const btn=document.getElementById('btn-stop-selected');
|
||||||
|
if(btn)btn.disabled=n===0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量停止选中的运行中设备
|
||||||
|
async function stopSelectedDevices(){
|
||||||
|
const serials=Array.from(_selectedSerials);
|
||||||
|
if(serials.length===0){showToast('请先勾选设备','error');return;}
|
||||||
|
// 只停止运行中/连接中的
|
||||||
|
const toStop=serials.filter(s=>{
|
||||||
|
const d=(_statusData.devices||[]).find(x=>x.serial===s);
|
||||||
|
return d&&(d.worker_status==='running'||d.worker_status==='connecting');
|
||||||
|
});
|
||||||
|
if(toStop.length===0){showToast('选中的设备没有运行中的任务','error');return;}
|
||||||
|
if(!confirm('确定停止选中的 '+toStop.length+' 台设备的任务吗?'))return;
|
||||||
|
let ok=0,fail=0;
|
||||||
|
for(const s of toStop){
|
||||||
|
const r=await apiPost('/api/stop_device',{serial:s});
|
||||||
|
if(r&&r.ok){ok++;}else{fail++;}
|
||||||
|
}
|
||||||
|
showToast('已停止 '+ok+' 台'+(fail>0?(',失败 '+fail+' 台'):''),ok>0?'success':'error');
|
||||||
|
setTimeout(loadMonitor,500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开截图弹窗
|
||||||
|
function openScreenshot(serial){
|
||||||
|
_shotSerial=serial;
|
||||||
|
_shotLoading=false;
|
||||||
|
// 渲染弹窗
|
||||||
|
document.getElementById('modal-title').textContent='设备画面 - '+serial;
|
||||||
|
document.getElementById('modal-body').innerHTML=
|
||||||
|
'<div id="shot-content" class="shot-loading">正在获取画面...</div>'+
|
||||||
|
'<div class="shot-toolbar">'+
|
||||||
|
'<button class="btn btn-primary btn-sm" onclick="refreshShot()">刷新</button>'+
|
||||||
|
'<label class="auto-label"><input type="checkbox" id="shot-auto" checked onchange="toggleShotAuto()"> 自动刷新(3秒)</label>'+
|
||||||
|
'<span class="shot-info" id="shot-time"></span>'+
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('modal-footer').innerHTML='<button class="btn" onclick="closeShotModal()">关闭</button>';
|
||||||
|
// 切换为截图弹窗样式(自适应大小)
|
||||||
|
document.getElementById('modal-box').classList.add('shot-modal');
|
||||||
|
showModal();
|
||||||
|
// 立即取一次
|
||||||
|
refreshShot();
|
||||||
|
// 开启自动刷新
|
||||||
|
startShotTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭截图弹窗(停掉定时器 + 关闭 modal + 还原样式)
|
||||||
|
function closeShotModal(){
|
||||||
|
stopShotTimer();
|
||||||
|
_shotSerial='';
|
||||||
|
document.getElementById('modal-box').classList.remove('shot-modal');
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShotAuto(){
|
||||||
|
const cb=document.getElementById('shot-auto');
|
||||||
|
if(cb&&cb.checked){startShotTimer();}else{stopShotTimer();}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startShotTimer(){
|
||||||
|
stopShotTimer();
|
||||||
|
_shotTimer=setInterval(refreshShot,3000);
|
||||||
|
}
|
||||||
|
function stopShotTimer(){
|
||||||
|
if(_shotTimer){clearInterval(_shotTimer);_shotTimer=null;}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取一次截图
|
||||||
|
async function refreshShot(){
|
||||||
|
if(!_shotSerial)return;
|
||||||
|
if(_shotLoading)return; // 上一次还没回来,跳过这次
|
||||||
|
_shotLoading=true;
|
||||||
|
const el=document.getElementById('shot-content');
|
||||||
|
if(!el){_shotLoading=false;return;}
|
||||||
|
// 首次或出错后显示 loading
|
||||||
|
if(!el.querySelector('img')){
|
||||||
|
el.className='shot-loading';
|
||||||
|
el.textContent='正在获取画面...';
|
||||||
|
}
|
||||||
|
const url='/api/device/screenshot?serial='+encodeURIComponent(_shotSerial)+'&t='+Date.now();
|
||||||
|
try{
|
||||||
|
const r=await fetch(url);
|
||||||
|
if(r.status===401){window.location='/login';return;}
|
||||||
|
if(!r.ok){
|
||||||
|
const j=await r.json().catch(()=>({}));
|
||||||
|
throw new Error(j.error||('HTTP '+r.status));
|
||||||
|
}
|
||||||
|
const blob=await r.blob();
|
||||||
|
const objUrl=URL.createObjectURL(blob);
|
||||||
|
el.className='';
|
||||||
|
el.innerHTML='<img class="shot-img" src="'+objUrl+'" alt="设备画面">';
|
||||||
|
// 更新时间
|
||||||
|
const tEl=document.getElementById('shot-time');
|
||||||
|
if(tEl)tEl.textContent='更新于 '+new Date().toLocaleTimeString();
|
||||||
|
// 释放上一个 objUrl(避免内存泄漏)
|
||||||
|
if(el._lastObjUrl)URL.revokeObjectURL(el._lastObjUrl);
|
||||||
|
el._lastObjUrl=objUrl;
|
||||||
|
}catch(e){
|
||||||
|
el.className='shot-err';
|
||||||
|
el.textContent='截图失败:'+e.message;
|
||||||
|
}finally{
|
||||||
|
_shotLoading=false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function apiDelete(url){
|
async function apiDelete(url){
|
||||||
const r=await fetch(url,{method:'DELETE'});
|
const r=await fetch(url,{method:'DELETE'});
|
||||||
@@ -415,6 +615,35 @@ async function loadMonitor(){
|
|||||||
document.getElementById('stats').innerHTML=cards.map(c=>
|
document.getElementById('stats').innerHTML=cards.map(c=>
|
||||||
'<div class="stat-card" style="--accent:'+c.c+'"><div class="stat-num" style="color:'+c.c+'">'+c.n+'</div><div class="stat-lbl">'+c.l+'</div></div>'
|
'<div class="stat-card" style="--accent:'+c.c+'"><div class="stat-num" style="color:'+c.c+'">'+c.n+'</div><div class="stat-lbl">'+c.l+'</div></div>'
|
||||||
).join('');
|
).join('');
|
||||||
|
// 渲染设备表格(含排序+多选)
|
||||||
|
renderDevices();
|
||||||
|
// 同步全选框状态
|
||||||
|
const all=document.getElementById('cb-all');
|
||||||
|
if(all){
|
||||||
|
all.checked=devices.length>0&&_selectedSerials.size===devices.length;
|
||||||
|
}
|
||||||
|
updateSelCount();
|
||||||
|
// 加载任务卡片
|
||||||
|
loadJobsForMonitor();
|
||||||
|
}catch(e){console.error(e);}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染设备表格(带排序,复选状态保持)
|
||||||
|
function renderDevices(){
|
||||||
|
if(!_statusData||!_statusData.devices){return;}
|
||||||
|
let devices=_statusData.devices.slice();
|
||||||
|
// 排序
|
||||||
|
if(_sortKey&&_sortDir){
|
||||||
|
const dir=_sortDir==='asc'?1:-1;
|
||||||
|
devices.sort((a,b)=>{
|
||||||
|
let va=_sortValue(a,_sortKey),vb=_sortValue(b,_sortKey);
|
||||||
|
if(typeof va==='string'&&typeof vb==='string'){
|
||||||
|
return va.localeCompare(vb)*dir;
|
||||||
|
}
|
||||||
|
va=Number(va)||0;vb=Number(vb)||0;
|
||||||
|
return (va-vb)*dir;
|
||||||
|
});
|
||||||
|
}
|
||||||
const rows=devices.map(dev=>{
|
const rows=devices.map(dev=>{
|
||||||
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
|
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
|
||||||
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
|
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
|
||||||
@@ -426,19 +655,22 @@ async function loadMonitor(){
|
|||||||
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
|
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
|
||||||
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
|
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
|
||||||
):'<span class="text-muted">-</span>';
|
):'<span class="text-muted">-</span>';
|
||||||
// 通用进度展示:解析 dev.progress = {done, total, unit, action_counts}
|
|
||||||
const prog=fmtProgress(dev.progress, dev.worker_status);
|
const prog=fmtProgress(dev.progress, dev.worker_status);
|
||||||
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
|
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
|
||||||
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
|
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
|
||||||
const btn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
|
const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
|
||||||
|
// 截图按钮(任何状态都可看,离线时会报错)
|
||||||
|
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
|
||||||
|
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+stopBtn+'</div>';
|
||||||
const devName=dev.device_name||dev.model||'';
|
const devName=dev.device_name||dev.model||'';
|
||||||
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
|
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
|
||||||
return '<tr><td><button class="btn btn-xs" onclick="identifyDevice(\''+esc(dev.serial)+'\')" title="响铃识别">识别</button> '+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btn+'</td></tr>';
|
// 复选框列
|
||||||
|
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||||||
|
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||||||
|
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
|
||||||
|
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
|
||||||
});
|
});
|
||||||
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="10" class="empty">暂无设备</td></tr>';
|
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="11" class="empty">暂无设备</td></tr>';
|
||||||
// 加载任务卡片
|
|
||||||
loadJobsForMonitor();
|
|
||||||
}catch(e){console.error(e);}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadJobsForMonitor(){
|
async function loadJobsForMonitor(){
|
||||||
@@ -1079,6 +1311,12 @@ function showModal(){
|
|||||||
document.getElementById('modal-overlay').classList.add('show');
|
document.getElementById('modal-overlay').classList.add('show');
|
||||||
}
|
}
|
||||||
function closeModal(){
|
function closeModal(){
|
||||||
|
// 清理截图弹窗的自动刷新定时器(避免关闭后后台继续请求)
|
||||||
|
stopShotTimer();
|
||||||
|
_shotSerial='';
|
||||||
|
// 还原弹窗样式(截图弹窗会加 shot-modal,关闭时移除)
|
||||||
|
const box=document.getElementById('modal-box');
|
||||||
|
if(box)box.classList.remove('shot-modal');
|
||||||
document.getElementById('modal-overlay').classList.remove('show');
|
document.getElementById('modal-overlay').classList.remove('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-20
@@ -15,7 +15,7 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, redirect, url_for, render_template
|
from flask import Flask, jsonify, request, redirect, url_for, render_template, Response
|
||||||
from flask_login import (LoginManager, login_user, logout_user,
|
from flask_login import (LoginManager, login_user, logout_user,
|
||||||
login_required, current_user)
|
login_required, current_user)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ from core.task_manager import TaskManager
|
|||||||
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
||||||
from core.models import db, init_db, User, DeviceGroup, TaskJob
|
from core.models import db, init_db, User, DeviceGroup, TaskJob
|
||||||
from core.apk_manager import ApkManager
|
from core.apk_manager import ApkManager
|
||||||
from core.adb_helper import identify_device
|
from core.adb_helper import screenshot
|
||||||
from tasks import list_task_types, get_task_class
|
from tasks import list_task_types, get_task_class
|
||||||
|
|
||||||
_log = get_logger("web")
|
_log = get_logger("web")
|
||||||
@@ -341,17 +341,24 @@ def api_release():
|
|||||||
return jsonify({"ok": True, "released": released})
|
return jsonify({"ok": True, "released": released})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/device/identify", methods=["POST"])
|
@app.route("/api/device/screenshot")
|
||||||
@login_required
|
@login_required
|
||||||
def api_device_identify():
|
def api_device_screenshot():
|
||||||
"""让设备响铃以供识别(直连 serial,不经过 STF)。"""
|
"""获取设备当前画面截图(PNG)。
|
||||||
serial = (request.json or {}).get("serial", "")
|
|
||||||
|
用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道,
|
||||||
|
任务运行中调用安全。直接返回 image/png,前端用 <img> 加载。
|
||||||
|
?serial=xxx 设备 serial
|
||||||
|
?t=123 时间戳,避免浏览器缓存(前端自动加)
|
||||||
|
"""
|
||||||
|
serial = request.args.get("serial", "")
|
||||||
if not serial:
|
if not serial:
|
||||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
ok, msg = identify_device(serial)
|
ok, data = screenshot(serial)
|
||||||
if ok:
|
if ok:
|
||||||
return jsonify({"ok": True, "msg": msg})
|
return Response(data, mimetype="image/png",
|
||||||
return jsonify({"ok": False, "error": msg}), 500
|
headers={"Cache-Control": "no-store"})
|
||||||
|
return jsonify({"ok": False, "error": data}), 500
|
||||||
|
|
||||||
|
|
||||||
# ================== API:应用管理(APK 上传/安装)==================
|
# ================== API:应用管理(APK 上传/安装)==================
|
||||||
@@ -407,20 +414,44 @@ def api_apks_install_status():
|
|||||||
return jsonify({"ok": True, "status": status})
|
return jsonify({"ok": True, "status": status})
|
||||||
|
|
||||||
|
|
||||||
|
def _run_server(host, port):
|
||||||
|
"""启动 Flask,自动处理端口冲突和权限问题。
|
||||||
|
|
||||||
|
常见失败原因:
|
||||||
|
WinError 10013 — 端口在 Windows 动态端口范围内被出站连接占用,
|
||||||
|
或非管理员绑定 0.0.0.0。前者换端口,后者降级 127.0.0.1。
|
||||||
|
WinError 10048 — 端口已被其他进程监听。换端口重试。
|
||||||
|
策略:原端口失败 → 试 127.0.0.1:原端口 → 试 127.0.0.1:原端口+1..+5
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
# 先探测原端口是否可用(避免 flask 报错打日志难看)
|
||||||
|
candidates = [(host, port)]
|
||||||
|
if host == "0.0.0.0":
|
||||||
|
candidates.append(("127.0.0.1", port))
|
||||||
|
for i in range(1, 6):
|
||||||
|
candidates.append(("127.0.0.1", port + i))
|
||||||
|
|
||||||
|
for h, p in candidates:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((h, p))
|
||||||
|
# 探测成功,启动 flask
|
||||||
|
if (h, p) != (host, port):
|
||||||
|
_log.warning(f"原端口 {host}:{port} 不可用,改用 {h}:{p}")
|
||||||
|
_log.info(f"启动服务: http://localhost:{p}/")
|
||||||
|
app.run(host=h, port=p, debug=False, threaded=True)
|
||||||
|
return
|
||||||
|
except OSError as e:
|
||||||
|
_log.warning(f"绑定 {h}:{p} 失败: {e}")
|
||||||
|
# 全部失败
|
||||||
|
raise OSError(f"无法绑定任何候选端口({host}:{port} 及 127.0.0.1:{port}~{port+5})")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from config import WEB_HOST, WEB_PORT
|
from config import WEB_HOST, WEB_PORT
|
||||||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||||||
try:
|
try:
|
||||||
try:
|
_run_server(WEB_HOST, WEB_PORT)
|
||||||
app.run(host=WEB_HOST, port=WEB_PORT, debug=False, threaded=True)
|
|
||||||
except OSError as e:
|
|
||||||
# 0.0.0.0 在 Windows 非管理员下会触发 WinError 10013
|
|
||||||
# 降级到 127.0.0.1 保证本机至少能访问,局域网访问需用管理员启动
|
|
||||||
if WEB_HOST == "0.0.0.0":
|
|
||||||
_log.warning(f"绑定 {WEB_HOST} 失败({e}),降级到 127.0.0.1(仅本机可访问)")
|
|
||||||
_log.warning("如需局域网访问,请用管理员身份运行 start_web.bat")
|
|
||||||
app.run(host="127.0.0.1", port=WEB_PORT, debug=False, threaded=True)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
mgr.shutdown()
|
mgr.shutdown()
|
||||||
|
|||||||
Reference in New Issue
Block a user