Files
auto_control/static/admin/tools.js
T

555 lines
26 KiB
JavaScript
Raw 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.
// 工具 Tab:剪贴板注入/应用版本/设备池管理 + adb终端 + Tailscale管理 + 远程看屏
// ================== 工具:剪贴板注入 ==================
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + 设备池)
let _clipSelected = new Set();
// 本地 adb 设备列表共享缓存(剪贴板/终端两个面板同源,10 秒内复用避免重复请求;force 强制刷新)
let _adbDevicesCache = null; // {ts, data}
async function _fetchAdbDevices(force){
const now = Date.now();
if(!force && _adbDevicesCache && now - _adbDevicesCache.ts < 10000){
return _adbDevicesCache.data;
}
const r = await apiGet('/api/adb/devices');
if(!r || !r.ok) return null;
_adbDevicesCache = {ts: now, data: r.devices || []};
return _adbDevicesCache.data;
}
async function loadToolsDevices(force){
const list = await _fetchAdbDevices(force);
const status = document.getElementById('clip-status');
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
// 剪贴板注入只对在线设备有意义:过滤掉设备池未连接(pool)/离线条目;
// adb 终端面板保留全部(重连设备需要),它用自己的渲染逻辑不受影响
_clipDevices = list.filter(d=>d.state==='device');
const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
status.textContent='';
return;
}
wrap.innerHTML = _clipDevices.map(d=>{
const st = d.state==='device' ? '已连接' : (d.state==='pool' ? '设备池' : d.state);
const stCls = d.state==='device' ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>';
}).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
// ================== AI 控制台(模型配置 + 指令执行 + 进度轮询) ==================
let _agentPoll = null;
function loadAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
const b=document.getElementById('agent-api-base');
if(!b)return; // 面板未渲染
b.value=r.api_base||'https://api.deepseek.com';
document.getElementById('agent-model').value=r.model||'';
document.getElementById('agent-default-serial').value=r.default_serial||'';
const hint=document.getElementById('agent-key-hint');
hint.textContent=r.api_key_masked?('已配置 '+r.api_key_masked):'未配置 Key';
});
}
function saveAgentConfig(){
const body={api_base:document.getElementById('agent-api-base').value.trim(),
model:document.getElementById('agent-model').value.trim(),
default_serial:document.getElementById('agent-default-serial').value.trim()};
const key=document.getElementById('agent-api-key').value.trim();
if(key)body.api_key=key;
apiPost('/api/agent/config',body).then(r=>{
if(r&&r.ok){showToast('配置已保存','success');loadAgentConfig();}
else showToast('保存失败: '+((r&&r.error)||''),'error');
});
}
function runAgent(){
const prompt=document.getElementById('agent-prompt').value.trim();
if(!prompt){showToast('请输入指令','error');return;}
apiPost('/api/agent/run',{prompt}).then(r=>{
if(r&&r.ok){
showToast('Agent 已启动','success');
document.getElementById('agent-status').textContent='运行中...';
document.getElementById('btn-agent-run').disabled=true;
document.getElementById('agent-answer-wrap').style.display='none';
startAgentPoll();
}else showToast('启动失败: '+((r&&r.error)||''),'error');
});
}
function startAgentPoll(){
if(_agentPoll)clearInterval(_agentPoll);
_agentPoll=setInterval(pollAgent,2000);
pollAgent();
}
function stopAgentPoll(){
if(_agentPoll){clearInterval(_agentPoll);_agentPoll=null;}
}
function pollAgent(){
apiGet('/api/agent/status').then(r=>{
if(!r||!r.ok)return;
const st=document.getElementById('agent-status');
if(r.state==='running'){
st.textContent='运行中... ('+(r.steps||[]).length+' 步)';
}else{
st.textContent=r.state==='done'?'完成':'失败';
document.getElementById('btn-agent-run').disabled=false;
stopAgentPoll();
}
// 步骤流(含截图缩略)
const steps=document.getElementById('agent-steps');
const html=(r.steps||[]).map(s=>{
const args=esc(s.args||'');
const img=s.image
?'<img src="data:image/jpeg;base64,'+s.image+'" style="max-width:120px;border-radius:6px;margin-top:4px;display:block">':'';
return '<div style="padding:6px 0;border-bottom:1px solid var(--ink-line)">'
+'<code style="color:var(--teal)">'+esc(s.tool||'')+'</code> '
+'<span class="text-muted">'+args+'</span>'+img+'</div>';
}).join('')||'';
steps.innerHTML=html;
// 最终回答
if(r.answer){
const wrap=document.getElementById('agent-answer-wrap');
wrap.style.display='block';
document.getElementById('agent-answer').textContent=r.answer;
}
if(r.error){
st.textContent='错误: '+r.error;
document.getElementById('btn-agent-run').disabled=false;
stopAgentPoll();
}
});
}
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
function loadDiscovery(){
apiGet('/api/devices/discovery').then(r=>{
if(!r||!r.ok)return;
const status=document.getElementById('discovery-status');
const enabled=document.getElementById('discovery-enabled');
const interval=document.getElementById('discovery-interval');
const subnets=document.getElementById('discovery-subnets');
if(!enabled)return; // 面板未渲染(离开子分栏)
enabled.checked=!!r.enabled;
interval.value=r.interval||60;
subnets.value=(r.subnets||[]).join(', ');
// 状态行
if(r.scanning){status.textContent='扫描中...';}
else if(r.last_scan){
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0);
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
status.textContent=txt;
}else{status.textContent='尚未扫描';}
// 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现)
const rows=(r.pending||[]).filter(x=>x.online);
const tb=document.getElementById('tb-discovery');
if(!tb)return;
if(!rows.length){
tb.innerHTML='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
}else{
tb.innerHTML=rows.map(x=>{
const src=x.source==='tailscale'
?'<span class="label label-info">Tailscale</span>'
:'<span class="label label-warning">局域网</span>';
const onl=x.online
?'<span class="label label-success">在线</span>'
:'<span class="label label-warning">离线</span>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">确认添加</button> '
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
}).join('');
}
});
}
function toggleDiscovery(checked){
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
else showToast('设置失败: '+((r&&r.error)||''),'error');
});
}
function saveDiscoverySettings(){
const subnets=(document.getElementById('discovery-subnets').value||'')
.split(',').map(s=>s.trim()).filter(Boolean);
const interval=parseInt(document.getElementById('discovery-interval').value)||60;
apiPost('/api/devices/discovery/settings',{subnets,interval}).then(r=>{
if(r&&r.ok)showToast('设置已保存','success');
else showToast('保存失败: '+((r&&r.error)||''),'error');
});
}
function runDiscoveryScan(){
apiPost('/api/devices/discovery/scan',{}).then(r=>{
if(r&&r.ok){showToast(r.msg||'扫描已启动','success');setTimeout(loadDiscovery,3000);}
else showToast('扫描失败: '+((r&&r.error)||''),'error');
});
}
function confirmDiscoveryDev(serial){
const name=prompt('备注名(可选,留空直接添加)','');
if(name===null)return;
apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{
if(r&&r.ok){
showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success');
loadDevPool();loadDiscovery();
}else showToast('确认失败: '+((r&&r.error)||''),'error');
});
}
function ignoreDiscoveryDev(serial){
if(!confirm('忽略 '+serial+'?下轮扫描可能再次发现'))return;
apiPost('/api/devices/discovery/ignore',{serial}).then(r=>{
if(r&&r.ok){showToast('已忽略','success');loadDiscovery();}
else showToast('操作失败: '+((r&&r.error)||''),'error');
});
}
function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value);
}
// 注入剪贴板(ClipInject APK 通道,Android 10+ 可用):
// am start 透明 Activity 写入 + u2 读回比对验证;结果逐台渲染 + toast 汇总
function injectClipboard(){
const text = document.getElementById('clip-text').value;
const serials = [..._clipSelected];
if(!serials.length){showToast('请先勾选目标设备','error');return;}
if(!text.trim()){showToast('请输入注入内容','error');return;}
const btn = Array.from(document.querySelectorAll('#tools-sub-clipboard .btn-primary'))
.find(b=>b.textContent.includes('注入剪贴板'));
if(btn){btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; btn._old = old;}
apiPost('/api/tools/clipboard/set', {serials, text}).then(r=>{
if(!r){showToast('请求失败','error');return;}
const rows = Object.entries(r.results||{}).map(([s,v])=>{
const ok = !!v.ok;
return '<tr><td style="font-family:monospace">'+esc(s)+'</td>'
+'<td><span class="label '+(ok?'label-success':'label-danger')+'">'
+(ok?'成功':'失败')+'</span> '+esc(v.msg||'')+'</td></tr>';
}).join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display='block';
const okc = r.ok_count||0, fail = r.fail_count||0;
if(fail>0) showToast(okc+' 台成功,'+fail+' 台失败','error');
else showToast(okc+' 台设备注入成功','success');
}).catch(e=>{showToast('注入异常: '+e,'error');})
.finally(()=>{ if(btn){btn.disabled=false; btn.textContent=btn._old;} });
}
// ================== 工具:应用版本管理 ==================
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
async function queryAppVers(){
const pkg = document.getElementById('appver-pkg').value.trim();
if(!pkg){showToast('请输入包名','error');return;}
const btn = document.querySelector('#tools-sub-appver .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '查询中...';
try{
const r = await apiPost('/api/tools/appver', {pkg});
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
_appVerRows = Object.entries(r.results||{}).map(([serial,v])=>({serial, ...v}));
setListPager('appver',{
data:()=>_appVerRows,
filter:(x,kw)=>((x.serial||'').toLowerCase().includes(kw)||(x.version_name||'').toLowerCase().includes(kw)||(x.version_code||'').toLowerCase().includes(kw)),
sortValue:(x,k)=>{
if(k==='state'){ if(x.error)return 2; return x.installed?0:1; }
if(k==='code')return parseInt(x.version_code)||0;
if(k==='version')return x.version_name||'';
return x.serial||'';
},
render:renderAppVerRow,
tbody:'tb-appver',pager:'pager-appver',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无可用设备</td></tr>'
});
applyListPager('appver');
initListSearch('appver','search-appver');
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}
// ================== Tab: 维护(仅管理员) ==================
// 服务器类命令(不需要 -s 目标设备)
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
function _selectedSerial(){
const sel=document.getElementById('adb-device');
return sel?sel.value:'';
}
// 选中目标设备时自动附加 -s <serial>;服务器类命令保持原样
function _attachDevice(cmd){
const serial=_selectedSerial();
if(!serial)return cmd;
if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加
const first=(cmd.split(/\s+/)[0]||'').toLowerCase();
if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd;
return '-s '+serial+' '+cmd;
}
async function loadAdbDevices(force){
const sel=document.getElementById('adb-device');
if(!sel)return;
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)'};
const opts=['<option value="">(不指定设备)</option>'].concat(
list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
)
);
sel.innerHTML=opts.join('');
if(cur)sel.value=cur;
}
function fillAdbCmd(cmd){
document.getElementById('adb-cmd-input').value=cmd;
}
async function _runAdbFull(full){
const out=document.getElementById('adb-output');
out.textContent='$ '+full+'\n执行中...';
const r=await apiPost('/api/adb/cmd',{cmd:full});
if(!r)return;
if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;}
out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']';
out.scrollTop=out.scrollHeight;
}
async function runAdbCmd(){
const input=document.getElementById('adb-cmd-input');
let cmd=(input.value||'').trim();
if(!cmd){showToast('请输入 adb 命令','error');return;}
cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices"
cmd=_attachDevice(cmd);
await _runAdbFull('adb '+cmd);
}
function reconnectAdbDevice(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbFull('adb connect '+serial);
}
// ================== Tab: 维护 - Tailscale 管理 ==================
// 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页)
let _tsDevices=[];
async function loadTailscaleDevices(){
const tb=document.getElementById('tb-tailscale');
const st=document.getElementById('ts-status');
const hint=document.getElementById('ts-hint');
if(!tb)return;
tb.innerHTML='<tr><td colspan="8" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/tailscale/devices');
if(!r)return;
if(!r.ok){
tb.innerHTML='<tr><td colspan="8" class="empty">'+esc(r.error||'获取失败')+'</td></tr>';
if(st)st.textContent='';
const s=await apiGet('/api/tailscale/status');
if(s&&s.ok&&!s.configured&&hint)hint.textContent='⚠ 未配置:'+esc(s.hint||'')+'(写入 .env 后重启服务生效)';
return;
}
if(hint)hint.textContent='';
_tsDevices=r.devices||[];
setListPager('tailscale',{
data:()=>_tsDevices,
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)),
sortValue:(d,k)=>{
if(k==='ip')return (d.addresses||[])[0]||'';
if(k==='online'){ if(d.online===true)return 0; if(d.online===false)return 2; return 1; } // null=最近12h在线
if(k==='authorized')return d.authorized?1:0;
if(k==='key_expiry')return d.key_expiry_disabled?1:0;
return d[k]||'';
},
render:renderTsRow,
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
});
applyListPager('tailscale');
initListSearch('tailscale','search-tailscale');
if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
}
function renderTsRow(d){
const ip=(d.addresses||[])[0]||'-';
return '<tr>'+
'<td><code>'+esc(d.hostname||'-')+'</code></td>'+
'<td>'+esc(d.name||'-')+'</td>'+
'<td style="font-family:monospace">'+esc(ip)+'</td>'+
'<td>'+esc(d.os||'-')+'</td>'+
'<td>'+((d.online===true)?'<span class="label label-success">在线</span>':(d.online===false?'<span class="label label-default">离线</span>':'<span class="label label-warning" title="12 小时内见过但当前未上报">最近在线</span>'))+'</td>'+
'<td>'+(d.authorized?'<span class="label label-success">已授权</span>':'<span class="label label-danger">未授权</span>')+'</td>'+
'<td>'+(d.key_expiry_disabled?'<span class="label label-warning">永不过期</span>':'<span class="label label-default">会过期</span>')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renameTailscaleDevice(\''+esc(d.id)+'\',\''+esc(d.name||'')+'\')">改名</button> '+
'<button class="btn btn-xs" onclick="setTsIp(\''+esc(d.id)+'\',\''+esc((d.addresses||[])[0]||'')+'\')" title="修改设备的 Tailscale IPv4(会断开当前连接,平台 serial 随之变化)">设置IP</button> '+
'<button class="btn btn-xs" onclick="toggleTsAuth(\''+esc(d.id)+'\','+(!d.authorized)+')">'+(d.authorized?'取消授权':'授权')+'</button> '+
'<button class="btn btn-xs" onclick="toggleTsKeyExpiry(\''+esc(d.id)+'\','+(!d.key_expiry_disabled)+')">'+(d.key_expiry_disabled?'恢复过期':'密钥不过期')+'</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteTailscaleDevice(\''+esc(d.id)+'\')">移除</button>'+
'</td></tr>';
}
function renameTailscaleDevice(id, current){
const name=prompt('修改显示名(只改名称,不影响主机名):', current||'');
if(name===null)return;
apiPost('/api/tailscale/devices/'+id,{name:name}).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function setTsIp(id, current){
const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(serial)随之变化,改完记得同步设备池/分组/任务目标。', (current||'').split(':')[0]||'');
if(ip===null)return;
const v=ip.trim();
if(!v){showToast('IP 不能为空','error');return;}
apiPost('/api/tailscale/devices/'+id+'/ip',{ipv4:v}).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function toggleTsAuth(id, authorized){
apiPost('/api/tailscale/devices/'+id,{authorized:authorized}).then(r=>{
if(!r)return;
if(r.ok){showToast(authorized?'已授权':'已取消授权','success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function toggleTsKeyExpiry(id, disabled){
apiPost('/api/tailscale/devices/'+id,{key_expiry_disabled:disabled}).then(r=>{
if(!r)return;
if(r.ok){showToast(disabled?'已设置密钥永不过期(设备不再被踢下线)':'已恢复密钥定期过期','success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function deleteTailscaleDevice(id){
if(!confirm('确定从 tailnet 移除该设备?\n设备下次上线需重新授权。'))return;
apiDelete('/api/tailscale/devices/'+id).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function openTailscaleAuthKey(){
const desc=prompt('auth key 用途说明(可空,仅支持英文/数字):','auto_control');
if(desc===null)return;
const reusable=confirm('允许复用(同一 key 给多台设备用)?\n确定=可复用,取消=一次性');
const secs=prompt('有效期(秒,默认 3600=1 小时):','3600');
if(secs===null)return;
const st=document.getElementById('ts-status');
if(st)st.textContent='正在生成 auth key...';
apiPost('/api/tailscale/authkey',{description:desc||'',reusable:reusable,expiry_seconds:parseInt(secs)||3600}).then(r=>{
if(!r)return;
if(st)st.textContent='';
if(r.ok){
const ov=document.getElementById('test-overlay');
document.getElementById('test-modal-title').textContent='Tailscale Auth Key(只显示一次)';
document.getElementById('test-modal-body').innerHTML=
'<div style="margin-bottom:10px">新设备执行 <code>tailscale up --authkey=...</code> 接入(默认已预授权)</div>'+
'<textarea id="ts-key-box" class="form-control" rows="4" style="font-family:monospace" readonly>'+esc(r.key||'')+'</textarea>'+
'<button class="btn btn-primary" style="margin-top:10px" onclick="copyTsKey()">复制</button>'+
' <span id="ts-key-status" class="text-muted"></span>';
ov.classList.add('show');
showToast('Auth Key 已生成','success');
}else{
showToast(r.error,'error');
}
});
}
function copyTsKey(){
const box=document.getElementById('ts-key-box');
if(!box)return;
box.select();
try{document.execCommand('copy');}catch(e){}
const s=document.getElementById('ts-key-status');
if(s)s.textContent='已复制';
}
// ================== Tab: 工具 - 设备池管理(本地 SQLite 清单) ==================
async function loadDevPool(){
const tb=document.getElementById('tb-devpool');
const status=document.getElementById('devpool-status');
if(!tb)return;
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
let rows=r.devices||[];
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
const onlyOnline=document.getElementById('devpool-filter-online')
? document.getElementById('devpool-filter-online').checked : false;
rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0));
if(onlyOnline)rows=rows.filter(x=>x.online);
const onlineCnt=rows.filter(x=>x.online).length;
status.textContent=onlyOnline
? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)'
: '共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
tb.innerHTML=rows.map(d=>{
const st= d.enabled
? '<span class="label label-success">启用</span>'
: '<span class="label label-default">停用</span>';
const online= d.online
? '<span class="label label-success">在线</span>'
: '<span class="label label-warning">离线</span>';
return '<tr>'+
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
'<td>'+esc(d.name||'')+'</td>'+
'<td>'+esc(d.model||'<span style="color:var(--text-light)">-</span>')+'</td>'+
'<td>'+online+'</td>'+
'<td>'+st+'</td>'+
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
'</td></tr>';
}).join('')||'<tr><td colspan="7" class="empty">设备池为空,添加第一台设备开始</td></tr>';
}
async function addPoolDev(){
const serial=document.getElementById('devpool-serial').value.trim();
const name=document.getElementById('devpool-name').value.trim();
if(!serial){showToast('请输入 serial','error');return;}
const r=await apiPost('/api/devices/pool/add',{serial,name});
if(!r)return;
if(r.ok){
showToast(r.msg+(serial.includes(':')?'(已尝试连接)':''),'success');
document.getElementById('devpool-serial').value='';
document.getElementById('devpool-name').value='';
loadDevPool();
}else showToast(r.error,'error');
}
async function removePoolDev(serial){
if(!confirm('从设备池删除 '+serial+'?\n\n删除后不再参与任务调度(不影响设备本身与其他功能)。'))return;
const r=await apiPost('/api/devices/pool/remove',{serial});
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadDevPool();}
else showToast(r.error,'error');
}
async function togglePoolDev(serial,enabled){
const r=await apiPost('/api/devices/pool/toggle',{serial,enabled});
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadDevPool();}
else showToast(r.error,'error');
}
async function reconnectPoolDevices(){
if(!confirm('并发 adb connect 设备池内全部设备?\n\n已连接的自动跳过。'))return;
const r=await apiPost('/api/devices/pool/reconnect',{});
if(!r)return;
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
else showToast(r.error,'error');
}
async function refreshPoolModels(){
const r=await apiPost('/api/devices/pool/refresh_models',{});
if(!r)return;
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
else showToast(r.error,'error');
}