feat: 新增设备画面截图、列表复选框/排序/批量停止,修复端口绑定失败
This commit is contained in:
@@ -27,7 +27,9 @@ else:
|
||||
|
||||
# ================== Web 后台 ==================
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""让设备响铃以供识别(直连 serial,不经过 STF)。
|
||||
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -52,6 +52,6 @@ echo ============================================
|
||||
echo Fix complete! Now:
|
||||
echo 1. Close ALL browser windows and reopen
|
||||
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 ============================================
|
||||
pause
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ if %errorlevel% neq 0 (
|
||||
|
||||
echo ============================================
|
||||
echo 管理后台已启动(局域网可访问)
|
||||
echo 本机访问: http://127.0.0.1:5050/
|
||||
echo 局域网访问: http://本机IP:5050/
|
||||
echo 本机访问: http://127.0.0.1:18050/
|
||||
echo 局域网访问: http://本机IP:18050/
|
||||
echo 默认账号: admin / admin123
|
||||
echo 按 Ctrl+C 停止
|
||||
echo ============================================
|
||||
|
||||
+269
-31
@@ -143,6 +143,27 @@ select.form-control{cursor:pointer}
|
||||
.toast.show{display:block}
|
||||
.toast-success{background:#dcfce7;color:#166534;border:1px solid #86efac}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -176,15 +197,25 @@ select.form-control{cursor:pointer}
|
||||
<div class="section-title">设备列表</div>
|
||||
<div class="toolbar">
|
||||
<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 btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</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>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>设备</th><th>型号</th><th>STF</th><th>任务状态</th><th>前台App</th><th>进度</th>
|
||||
<th>当前任务</th><th>当前动作</th><th>最近错误</th><th>操作</th>
|
||||
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></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>
|
||||
<tbody id="tb-devices"></tbody>
|
||||
</table>
|
||||
@@ -289,7 +320,7 @@ select.form-control{cursor:pointer}
|
||||
|
||||
<!-- ========== 模态框 ========== -->
|
||||
<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">
|
||||
<h3 id="modal-title">标题</h3>
|
||||
<button class="close" onclick="closeModal()">×</button>
|
||||
@@ -326,12 +357,181 @@ async function apiPut(url,body){
|
||||
if(r.status===401){window.location='/login';return null;}
|
||||
return r.json();
|
||||
}
|
||||
// 识别设备:让设备响铃 3 秒
|
||||
async function identifyDevice(serial){
|
||||
showToast('正在连接设备响铃...','');
|
||||
const r=await apiPost('/api/device/identify',{serial:serial});
|
||||
if(r&&r.ok){showToast(r.msg||'已响铃','success');}
|
||||
else{showToast(r?r.error:'响铃失败','error');}
|
||||
|
||||
// ================== 设备列表排序与多选 ==================
|
||||
let _sortKey=''; // 当前排序字段
|
||||
let _sortDir=''; // '' | 'asc' | 'desc'
|
||||
let _selectedSerials=new Set(); // 已勾选的 serial 集合
|
||||
|
||||
// 点击表头排序
|
||||
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){
|
||||
const r=await fetch(url,{method:'DELETE'});
|
||||
@@ -415,32 +615,64 @@ async function loadMonitor(){
|
||||
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>'
|
||||
).join('');
|
||||
const rows=devices.map(dev=>{
|
||||
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 st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
|
||||
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
|
||||
const fgApp=dev.foreground_app;
|
||||
const fg=fgApp?(
|
||||
fgApp==='(他人占用)'?'<span class="label label-warning">他人占用</span>':
|
||||
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
|
||||
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
|
||||
):'<span class="text-muted">-</span>';
|
||||
// 通用进度展示:解析 dev.progress = {done, total, unit, action_counts}
|
||||
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 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 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>';
|
||||
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>';
|
||||
});
|
||||
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="10" class="empty">暂无设备</td></tr>';
|
||||
// 渲染设备表格(含排序+多选)
|
||||
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 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 st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
|
||||
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
|
||||
const fgApp=dev.foreground_app;
|
||||
const fg=fgApp?(
|
||||
fgApp==='(他人占用)'?'<span class="label label-warning">他人占用</span>':
|
||||
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
|
||||
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
|
||||
):'<span class="text-muted">-</span>';
|
||||
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 retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
|
||||
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 devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
|
||||
// 复选框列
|
||||
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="11" class="empty">暂无设备</td></tr>';
|
||||
}
|
||||
|
||||
async function loadJobsForMonitor(){
|
||||
try{
|
||||
const r=await apiGet('/api/jobs');if(!r||!r.ok)return;
|
||||
@@ -1079,6 +1311,12 @@ function showModal(){
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
+51
-20
@@ -15,7 +15,7 @@
|
||||
import os
|
||||
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,
|
||||
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.models import db, init_db, User, DeviceGroup, TaskJob
|
||||
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
|
||||
|
||||
_log = get_logger("web")
|
||||
@@ -341,17 +341,24 @@ def api_release():
|
||||
return jsonify({"ok": True, "released": released})
|
||||
|
||||
|
||||
@app.route("/api/device/identify", methods=["POST"])
|
||||
@app.route("/api/device/screenshot")
|
||||
@login_required
|
||||
def api_device_identify():
|
||||
"""让设备响铃以供识别(直连 serial,不经过 STF)。"""
|
||||
serial = (request.json or {}).get("serial", "")
|
||||
def api_device_screenshot():
|
||||
"""获取设备当前画面截图(PNG)。
|
||||
|
||||
用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道,
|
||||
任务运行中调用安全。直接返回 image/png,前端用 <img> 加载。
|
||||
?serial=xxx 设备 serial
|
||||
?t=123 时间戳,避免浏览器缓存(前端自动加)
|
||||
"""
|
||||
serial = request.args.get("serial", "")
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
ok, msg = identify_device(serial)
|
||||
ok, data = screenshot(serial)
|
||||
if ok:
|
||||
return jsonify({"ok": True, "msg": msg})
|
||||
return jsonify({"ok": False, "error": msg}), 500
|
||||
return Response(data, mimetype="image/png",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
return jsonify({"ok": False, "error": data}), 500
|
||||
|
||||
|
||||
# ================== API:应用管理(APK 上传/安装)==================
|
||||
@@ -407,20 +414,44 @@ def api_apks_install_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__":
|
||||
from config import WEB_HOST, WEB_PORT
|
||||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||||
try:
|
||||
try:
|
||||
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
|
||||
_run_server(WEB_HOST, WEB_PORT)
|
||||
finally:
|
||||
mgr.shutdown()
|
||||
|
||||
Reference in New Issue
Block a user