// 工具 Tab:剪贴板注入/应用版本/STF设备管理 + 维护(重启STF) + adb终端 + Tailscale管理 // ================== 工具:剪贴板注入 ================== let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池) 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;} _clipDevices = list; const wrap = document.getElementById('clip-devices'); if(!_clipDevices.length){ wrap.innerHTML='
无可用设备(本地 adb 或 STF 池均无)
'; status.textContent=''; return; } wrap.innerHTML = _clipDevices.map(d=>{ const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : (d.state==='stf_not_ready' ? 'STF未就绪' : d.state)); const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning'; return ''; }).join(''); status.textContent = '共 '+_clipDevices.length+' 台设备'; } function toggleClipDevice(cb){ if(cb.checked)_clipSelected.add(cb.value); else _clipSelected.delete(cb.value); } // ================== 工具:应用版本管理 ================== 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:'无可用设备' }); 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 ''+esc(x.serial)+''+ '查询失败'+esc(x.error)+''; if(x.installed)return ''+esc(x.serial)+''+ '已安装'+ ''+esc(x.version_name||'')+''+esc(x.version_code||'')+''; return ''+esc(x.serial)+''+ '未安装--'; } // ================== 工具:STF 设备管理 ================== let _stfDevRows = []; // [{ip, inCfg, state}] async function loadStfDevMgmt(){ const status = document.getElementById('stfdev-status'); const r = await apiGet('/api/stf/devmgmt/status'); if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;} const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP) const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化) (r.connected || []).forEach(d=>{ const s = d.serial; connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state; }); _stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({ ip, inCfg: configured.includes(ip), state: connected[ip]||'' })); setListPager('stfdev',{ data:()=>_stfDevRows, filter:(x,kw)=>x.ip.toLowerCase().includes(kw), sortValue:(x,k)=>{ if(k==='cfg')return x.inCfg?1:0; if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; } return x.ip||''; }, render:renderStfDevRow, tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10, empty:'无设备(脚本未配置、220 adb 也无连接)' }); applyListPager('stfdev'); initListSearch('stfdev','search-stfdev'); status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台'; } function renderStfDevRow(x){ const stHtml = x.state==='device' ? '已连接' : (x.state ? ''+esc(x.state)+'' : '未连接'); return ''+esc(x.ip)+''+ ''+(x.inCfg?'脚本中':'未配置')+''+ ''+stHtml+''+ ''+ ' '+ ''+ ''; } // 一键重连:手动运行 220 的 connect_devices.sh,补连所有配置设备 async function reconnectStfDevices(){ if(!confirm('立即运行 220 上的 connect_devices.sh?\n\n会补连脚本里所有未连接的设备(在线设备跳过)。'))return; const r=await apiPost('/api/stf/devmgmt/reconnect',{}); if(!r)return; const box=document.getElementById('stfdev-reconnect-out'); if(r.ok){ box.style.display='block'; box.textContent=r.output||'执行完成'; showToast('重连完成','success'); loadStfDevMgmt(); }else{ showToast('重连失败: '+(r.error||''),'error'); } } // 彻底删除:脚本移除 + 断开 + 清 STF 池记录(设备从池中彻底消失) async function deleteStfDev(ip){ if(!confirm('彻底删除 '+ip+'?\n\n1. 从 connect_devices.sh 移除\n2. 断开 220 的 adb 连接\n3. 调用 STF API 清除设备记录(设备从设备池彻底消失,需重新添加才会回来)'))return; const r=await apiPost('/api/stf/devmgmt/delete',{ip:ip}); if(!r)return; if(r.ok){ showToast((r.msgs||[]).join(';'),'success'); loadStfDevMgmt(); }else showToast('删除失败: '+(r.error||''),'error'); } async function addStfDev(){ const ip = document.getElementById('stfdev-ip').value.trim(); if(!ip){showToast('请输入设备 IP','error');return;} const r = await apiPost('/api/stf/devmgmt/add', {ip:ip}); if(!r)return; if(r.ok){ showToast((r.msgs||[]).join(';'),'success'); document.getElementById('stfdev-ip').value=''; loadStfDevMgmt(); }else showToast('添加失败: '+(r.error||''),'error'); } async function removeStfDev(ip){ if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return; const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip}); if(!r)return; if(r.ok){ showToast((r.msgs||[]).join(';'),'success'); loadStfDevMgmt(); }else showToast('移除失败: '+(r.error||''),'error'); } async function injectClipboard(){ const text = document.getElementById('clip-text').value.trim(); if(!text){showToast('请输入要注入的文字','error');return;} if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;} const btn = document.querySelector('#tools-sub-clipboard .btn-primary'); btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; try{ const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text}); if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;} const rows = Object.entries(r.results).map(([s,v])=> ''+esc(s)+''+(v.ok?'成功':'失败 '+esc(v.msg))+'').join(''); document.getElementById('clip-result').innerHTML = rows; document.getElementById('clip-result-wrap').style.display = 'block'; showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success'); } finally { btn.disabled = false; btn.textContent = old; } } // ================== 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 ;服务器类命令保持原样 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:'离线',stf:'STF池(未连接)',stf_not_ready:'STF未就绪'}; const opts=[''].concat( list.map(d=> '' ) ); 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); } // 修复类命令:目标为 IP:port 时先 adb connect(已连接自动跳过), // 否则设备不在本机 adb server 里会报 "device not found"(STF 未就绪的设备尤其如此)。 async function _runAdbRepair(full){ const serial=_selectedSerial(); if(serial && serial.indexOf(':')>=0){ await _runAdbFull('adb connect '+serial); } await _runAdbFull(full); } function uninstallStfAgent(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return; _runAdbRepair('adb -s '+serial+' uninstall jp.co.cyberagent.stf'); } function reconnectAdbDevice(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} _runAdbFull('adb connect '+serial); } function checkStfAgent(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} _runAdbRepair('adb -s '+serial+' shell pm list packages | grep cyberagent'); } async function restartStf(){ if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return; const st=document.getElementById('stf-restart-status'); st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...'; const r=await apiPost('/api/stf/restart',{}); if(!r)return; if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');} else{st.textContent='❌ '+r.error;showToast(r.error,'error');} } // 测试此步骤弹窗关闭 function closeTestModal(){ const ov=document.getElementById('test-overlay'); if(ov)ov.classList.remove('show'); } // ================== 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='加载中...'; const r=await apiGet('/api/tailscale/devices'); if(!r)return; if(!r.ok){ tb.innerHTML=''+esc(r.error||'获取失败')+''; 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:'tailnet 暂无设备' }); applyListPager('tailscale'); initListSearch('tailscale','search-tailscale'); if(st)st.textContent='共 '+_tsDevices.length+' 台设备'; } function renderTsRow(d){ const ip=(d.addresses||[])[0]||'-'; return ''+ ''+esc(d.hostname||'-')+''+ ''+esc(d.name||'-')+''+ ''+esc(ip)+''+ ''+esc(d.os||'-')+''+ ''+((d.online===true)?'在线':(d.online===false?'离线':'最近在线'))+''+ ''+(d.authorized?'已授权':'未授权')+''+ ''+(d.key_expiry_disabled?'永不过期':'会过期')+''+ ''+ ' '+ ' '+ ' '+ ' '+ ''+ ''; } 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 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (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= '
新设备执行 tailscale up --authkey=... 接入(默认已预授权)
'+ ''+ ''+ ' '; 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: 工具 - 远程看屏(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:'设备池(未连接)',stf_not_ready:'STF未就绪'}; sel.innerHTML=list.map(d=> '' ).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}); })();